From 3d383f2e259f59066e6d7f949939d4b15b784a62 Mon Sep 17 00:00:00 2001 From: primakov <primakovpro@gmail.com> Date: Sun, 23 Mar 2025 21:45:16 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B=20=D1=81=D1=82=D0=B8=D0=BB=D0=B8=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BC=D0=BF=D0=BE=D0=BD=D0=B5=D0=BD=D1=82=D0=B0=20UserCard=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D1=83=D0=BB=D1=83=D1=87=D1=88=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=B2=D0=B8=D0=B7=D1=83=D0=B0=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D0=BE=D0=B3=D0=BE=20=D0=B2=D0=BE=D1=81=D0=BF=D1=80=D0=B8=D1=8F?= =?UTF-8?q?=D1=82=D0=B8=D1=8F=20=D0=B8=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=B0=D0=BD=D0=B8=D0=BC=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D0=B8=20=D0=BF=D1=80=D0=B8=20=D0=BD=D0=B0=D0=B2=D0=B5?= =?UTF-8?q?=D0=B4=D0=B5=D0=BD=D0=B8=D0=B8.=20=D0=A0=D0=B5=D0=B0=D0=BB?= =?UTF-8?q?=D0=B8=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=D0=B0=20=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D0=B4=D0=B5=D1=80=D0=B6=D0=BA=D0=B0=20=D0=BE=D1=82=D0=BE=D0=B1?= =?UTF-8?q?=D1=80=D0=B0=D0=B6=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BD=D0=B5=D0=B4?= =?UTF-8?q?=D0=B0=D0=B2=D0=BD=D0=BE=20=D0=BF=D1=80=D0=B8=D1=81=D1=83=D1=82?= =?UTF-8?q?=D1=81=D1=82=D0=B2=D1=83=D1=8E=D1=89=D0=B8=D1=85=20=D1=81=D1=82?= =?UTF-8?q?=D1=83=D0=B4=D0=B5=D0=BD=D1=82=D0=BE=D0=B2=20=D1=81=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BC=D0=BE=D1=89=D1=8C=D1=8E=20=D0=B0=D0=BD=D0=B8=D0=BC?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B8.=20=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=20=D0=BA=D0=BE=D0=BC=D0=BF=D0=BE=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=20LessonDetail=20=D0=B4=D0=BB=D1=8F=20=D0=BE=D1=82?= =?UTF-8?q?=D1=81=D0=BB=D0=B5=D0=B6=D0=B8=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D1=8B=D1=85=20=D1=81=D1=82=D1=83=D0=B4=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=BE=D0=B2=20=D0=B8=20=D0=B8=D1=85=20=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=BC=D0=B0=D1=86=D0=B8=D0=B8=20=D0=BF=D1=80=D0=B8=20?= =?UTF-8?q?=D0=BF=D0=BE=D1=8F=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B8.=20?= =?UTF-8?q?=D0=A3=D0=BB=D1=83=D1=87=D1=88=D0=B5=D0=BD=D1=8B=20=D1=81=D1=82?= =?UTF-8?q?=D0=B8=D0=BB=D0=B8=20=D1=81=D0=BF=D0=B8=D1=81=D0=BA=D0=BE=D0=B2?= =?UTF-8?q?=20=D1=81=D1=82=D1=83=D0=B4=D0=B5=D0=BD=D1=82=D0=BE=D0=B2=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D0=BB=D1=83=D1=87=D1=88=D0=B5=D0=B9=20?= =?UTF-8?q?=D0=B0=D0=B4=D0=B0=D0=BF=D1=82=D0=B8=D0=B2=D0=BD=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=B8=20=D0=B8=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D1=82=D0=B5=D0=BB=D1=8C=D1=81=D0=BA=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=20=D0=BE=D0=BF=D1=8B=D1=82=D0=B0.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/user-card/style.ts | 137 +++++++++--- src/components/user-card/user-card.tsx | 40 ++-- src/pages/lesson-details.tsx | 123 ++++++++--- src/pages/style.ts | 99 ++++++++- src/pages/user-page.tsx | 197 +++++++++++++++--- .../lessons/access-code/create/success.json | 32 +-- 6 files changed, 497 insertions(+), 131 deletions(-) diff --git a/src/components/user-card/style.ts b/src/components/user-card/style.ts index 1e90f6e..1691921 100644 --- a/src/components/user-card/style.ts +++ b/src/components/user-card/style.ts @@ -1,26 +1,96 @@ import styled from '@emotion/styled' import { css, keyframes } from '@emotion/react' -export const Avatar = styled.img` - width: 96px; - height: 96px; - margin: 0 auto; - border-radius: 6px; +// Правильное определение анимации с помощью keyframes +const fadeIn = keyframes` + from { + opacity: 0; + } + to { + opacity: 1; + } ` +const pulse = keyframes` + 0% { + box-shadow: 0 0 0 0 rgba(72, 187, 120, 0.4); + } + 70% { + box-shadow: 0 0 0 10px rgba(72, 187, 120, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(72, 187, 120, 0); + } +` + +export const Avatar = styled.img` + width: 100%; + height: 100%; + border-radius: 12px; + object-fit: cover; + transition: transform 0.3s ease; +` + +export const NameOverlay = styled.div` + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 8px; + background: linear-gradient(transparent, rgba(0, 0, 0, 0.7)); + color: white; + border-bottom-left-radius: 12px; + border-bottom-right-radius: 12px; + font-size: 14px; + font-weight: 500; + text-align: center; + opacity: 0.9; + transition: opacity 0.3s ease; + + .chakra-ui-dark & { + background: linear-gradient(transparent, rgba(0, 0, 0, 0.8)); + } +` + +// Стили без интерполяций компонентов export const Wrapper = styled.div<{ warn?: boolean; width?: string | number }>` list-style: none; - background-color: var(--chakra-colors-white); - padding: 16px; - border-radius: 12px; - box-shadow: 2px 2px 6px var(--chakra-colors-blackAlpha-400); - transition: all 0.5; position: relative; - width: 180px; - min-height: 190px; - max-height: 200px; - margin-right: 12px; - padding-bottom: 22px; + border-radius: 12px; + width: 100%; + aspect-ratio: 1; + overflow: hidden; + cursor: pointer; + animation: ${fadeIn} 0.5s ease; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + transition: transform 0.3s ease, box-shadow 0.3s ease; + + &:hover { + transform: translateY(-5px); + box-shadow: 0 6px 12px rgba(0, 0, 0, 0.15); + } + + &:hover img { + transform: scale(1.05); + } + + &:hover > div:last-of-type:not(button) { + opacity: 1; + } + + &.recent { + animation: ${pulse} 1.5s infinite; + border: 2px solid var(--chakra-colors-green-400); + } + + .chakra-ui-dark & { + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); + + &.recent { + border: 2px solid var(--chakra-colors-green-300); + } + } + ${({ width }) => width ? css` @@ -31,35 +101,36 @@ export const Wrapper = styled.div<{ warn?: boolean; width?: string | number }>` ${(props) => props.warn ? css` - background-color: var(--chakra-colors-blackAlpha-800); opacity: 0.7; - color: var(--chakra-colors-gray-200); + filter: grayscale(0.8); ` : ''} - - .chakra-ui-dark & { - background-color: var(--chakra-colors-gray-700); - color: var(--chakra-colors-white); - box-shadow: 2px 2px 6px var(--chakra-colors-blackAlpha-600); - } - - .chakra-ui-dark &.warn { - background-color: var(--chakra-colors-blackAlpha-900); - color: var(--chakra-colors-gray-300); - } ` export const AddMissedButton = styled.button` position: absolute; bottom: 8px; - right: 12px; + right: 8px; border: none; - background-color: transparent; - opacity: 0.2; - color: inherit; + background-color: var(--chakra-colors-blue-500); + color: white; + width: 26px; + height: 26px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + z-index: 2; + opacity: 0.8; + transition: opacity 0.3s ease, transform 0.3s ease; - :hover { + &:hover { cursor: pointer; opacity: 1; + transform: scale(1.1); + } + + .chakra-ui-dark & { + background-color: var(--chakra-colors-blue-400); } ` \ No newline at end of file diff --git a/src/components/user-card/user-card.tsx b/src/components/user-card/user-card.tsx index 48246df..c9c667b 100644 --- a/src/components/user-card/user-card.tsx +++ b/src/components/user-card/user-card.tsx @@ -1,10 +1,11 @@ import React from 'react' import { sha256 } from 'js-sha256' -import { useColorMode } from '@chakra-ui/react' +import { Box, useColorMode } from '@chakra-ui/react' +import { CheckCircleIcon, AddIcon } from '@chakra-ui/icons' import { User } from '../../__data__/model' -import { AddMissedButton, Avatar, Wrapper } from './style' +import { AddMissedButton, Avatar, Wrapper, NameOverlay } from './style' export function getGravatarURL(email, user) { if (!email) return void 0 @@ -17,15 +18,17 @@ export function getGravatarURL(email, user) { export const UserCard = ({ student, present, - onAddUser, - wrapperAS, - width + onAddUser = undefined, + wrapperAS = 'div', + width, + recentlyPresent = false }: { student: User present: boolean width?: string | number onAddUser?: (user: User) => void wrapperAS?: React.ElementType<any, keyof React.JSX.IntrinsicElements>; + recentlyPresent?: boolean }) => { const { colorMode } = useColorMode(); @@ -34,25 +37,22 @@ export const UserCard = ({ warn={!present} as={wrapperAS} width={width} - className={!present ? 'warn' : ''} + className={!present ? 'warn' : recentlyPresent ? 'recent' : ''} > - <Avatar src={student.picture || getGravatarURL(student.email, null)} /> - <p style={{ - marginTop: 6, - color: colorMode === 'light' ? 'inherit' : 'var(--chakra-colors-gray-100)' - }}> - {student.name || student.preferred_username}{' '} - </p> + <Avatar src={student.picture || getGravatarURL(student.email, null)} alt={student.name || student.preferred_username} /> + <NameOverlay> + {student.name || student.preferred_username} + {present && ( + <Box as="span" ml={2} display="inline-block" color={recentlyPresent ? "green.100" : "green.300"}> + <CheckCircleIcon boxSize={3} /> + </Box> + )} + </NameOverlay> {onAddUser && !present && ( - <AddMissedButton onClick={() => onAddUser(student)}> - add + <AddMissedButton onClick={() => onAddUser(student)} aria-label="Отметить присутствие"> + <AddIcon boxSize={3} /> </AddMissedButton> )} </Wrapper> ) } - -UserCard.defaultProps = { - wrapperAS: 'div', - onAddUser: void 0, -} diff --git a/src/pages/lesson-details.tsx b/src/pages/lesson-details.tsx index c405320..5699f00 100644 --- a/src/pages/lesson-details.tsx +++ b/src/pages/lesson-details.tsx @@ -4,6 +4,7 @@ import dayjs from 'dayjs' import QRCode from 'qrcode' import { sha256 } from 'js-sha256' import { getConfigValue, getNavigationValue } from '@brojs/cli' +import { motion, AnimatePresence } from 'framer-motion' import { Box, Breadcrumb, @@ -13,6 +14,7 @@ import { VStack, Heading, Stack, + useColorMode, } from '@chakra-ui/react' import { useTranslation } from 'react-i18next' @@ -42,6 +44,10 @@ const LessonDetail = () => { const canvRef = useRef(null) const user = useAppSelector((s) => s.user) const { t } = useTranslation() + const { colorMode } = useColorMode() + + // Создаем ref для отслеживания ранее присутствовавших студентов + const prevPresentStudentsRef = useRef(new Set<string>()) const { isFetching, @@ -64,6 +70,20 @@ const LessonDetail = () => { [accessCode, lessonId], ) + // Эффект для обнаружения и обновления новых присутствующих студентов + useEffect(() => { + if (accessCode?.body) { + const currentPresent = new Set(accessCode.body.lesson.students.map(s => s.sub)) + + // Очищаем флаги предыдущего состояния после задержки + const timeoutId = setTimeout(() => { + prevPresentStudentsRef.current = currentPresent + }, 3000) + + return () => clearTimeout(timeoutId) + } + }, [accessCode]) + useEffect(() => { if (manualAddRqst.isSuccess) { refetch() @@ -118,13 +138,17 @@ const LessonDetail = () => { }, [isFetching, isSuccess, userUrl]) const studentsArr = useMemo(() => { - let allStudents: (User & { present?: boolean })[] = [ + let allStudents: (User & { present?: boolean; recentlyPresent?: boolean })[] = [ ...(AllStudents.data?.body || []), - ].map((st) => ({ ...st, present: false })) + ].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() @@ -132,13 +156,18 @@ const LessonDetail = () => { if (present) { present.present = true + present.recentlyPresent = newlyPresent.includes(student.sub) } else { - allStudents.push({ ...student, present: true }) + allStudents.push({ + ...student, + present: true, + recentlyPresent: newlyPresent.includes(student.sub) + }) } } return allStudents.sort((a, b) => (a.present ? -1 : 1)) - }, [accessCode?.body, AllStudents.data]) + }, [accessCode?.body, AllStudents.data, prevPresentStudentsRef.current]) return ( <> @@ -170,32 +199,76 @@ const LessonDetail = () => { {t('journal.pl.lesson.topicTitle')} </Heading> <Box as="span">{accessCode?.body?.lesson?.name}</Box> - <Box as="span"> - {dayjs(accessCode?.body?.lesson?.date).format(t('journal.pl.lesson.dateFormat'))}{' '} - {t('journal.pl.common.marked')} - {accessCode?.body?.lesson?.students?.length}{' '} - {AllStudents.isSuccess - ? `/ ${AllStudents?.data?.body?.length}` - : ''}{' '} - {t('journal.pl.common.people')} - </Box> + </VStack> - <Stack spacing="8" direction={{ base: "column", md: "row" }}> - <Box flexShrink={0} alignSelf="center"> + <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 as="span"> + {dayjs(accessCode?.body?.lesson?.date).format(t('journal.pl.lesson.dateFormat'))}{' '} + {t('journal.pl.common.marked')} - {accessCode?.body?.lesson?.students?.length}{' '} + {AllStudents.isSuccess + ? `/ ${AllStudents?.data?.body?.length}` + : ''}{' '} + {t('journal.pl.common.people')} + </Box> <a href={userUrl}> <QRCanvas ref={canvRef} /> </a> </Box> - <StudentList> - {isTeacher(user) && studentsArr.map((student) => ( - <UserCard - wrapperAS="li" - key={student.sub} - student={student} - present={student.present} - onAddUser={(user: User) => manualAdd({ lessonId, user })} - /> - ))} - </StudentList> + <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} + layout + initial={{ opacity: 0, scale: 0.8 }} + animate={{ + opacity: 1, + scale: 1, + // Добавляем подсветку для недавно отметившихся студентов + 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)' + }} + exit={{ opacity: 0, scale: 0.8 }} + transition={{ + type: "spring", + stiffness: 300, + damping: 30, + layout: { duration: 0.4 }, + boxShadow: { + repeat: student.recentlyPresent ? 3 : 0, + duration: 1.5 + } + }} + > + <UserCard + wrapperAS="div" + student={student} + present={student.present} + recentlyPresent={student.recentlyPresent} + onAddUser={(user: User) => manualAdd({ lessonId, user })} + /> + </motion.li> + ))} + </AnimatePresence> + )} + </StudentList> + </Box> </Stack> </Container> </> diff --git a/src/pages/style.ts b/src/pages/style.ts index 2588042..d245088 100644 --- a/src/pages/style.ts +++ b/src/pages/style.ts @@ -16,19 +16,96 @@ const reveal = keyframes` ` export const StudentList = styled.ul` - padding-left: 0px; - height: 600px; - justify-content: space-evenly; - padding-right: 20px; - display: flex; - flex-direction: row; - flex-wrap: wrap; - gap: 8px; + padding: 0; + list-style: none; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 16px; + width: 100%; + max-height: 600px; + overflow-y: auto; @media (max-width: 768px) { - height: auto; - max-height: 600px; - padding-right: 0; + gap: 12px; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + } + + /* Стили для motion.li элементов */ + li { + list-style: none; + height: 100%; + } + + &::-webkit-scrollbar { + width: 8px; + } + + &::-webkit-scrollbar-track { + background: rgba(0, 0, 0, 0.05); + border-radius: 4px; + } + + &::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.2); + border-radius: 4px; + } + + .chakra-ui-dark &::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.05); + } + + .chakra-ui-dark &::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.2); + } +` + +export const StudentListView = styled.ul` + padding: 0; + list-style: none; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 16px; + width: 100%; + + /* Адаптивные отступы на разных экранах */ + @media (max-width: 768px) { + gap: 12px; + grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); + } + + /* Стили для контейнеров карточек */ + li { + list-style: none; + height: 100%; + transform-origin: center bottom; + } + + /* Добавляем плавные переходы между состояниями */ + li:hover { + z-index: 10; + } + + /* Стилизация скроллбара */ + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + background: rgba(0, 0, 0, 0.03); + border-radius: 4px; + } + + &::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.1); + border-radius: 4px; + } + + .chakra-ui-dark &::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.03); + } + + .chakra-ui-dark &::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.1); } ` diff --git a/src/pages/user-page.tsx b/src/pages/user-page.tsx index 1bf4392..ebddd0e 100644 --- a/src/pages/user-page.tsx +++ b/src/pages/user-page.tsx @@ -1,6 +1,7 @@ -import React from 'react' +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 dayjs from 'dayjs' @@ -12,18 +13,64 @@ import { Container, Spinner, Text, + Heading, + Badge, + Flex, + useColorMode, } from '@chakra-ui/react' import { UserCard } from '../components/user-card' +import { StudentListView } from './style' const UserPage = () => { const { lessonId, accessId } = useParams() const { t } = useTranslation() + const { colorMode } = useColorMode() const acc = api.useGetAccessQuery({ accessCode: accessId }) + const [animatedStudents, setAnimatedStudents] = useState([]) const ls = api.useLessonByIdQuery(lessonId, { pollingInterval: 1000, skipPollingIfUnfocused: true, }) + + // Эффект для поэтапного появления карточек студентов + useEffect(() => { + if (ls.data?.body?.students?.length) { + // Сначала очищаем список + setAnimatedStudents([]) + + // Затем постепенно добавляем студентов для красивой анимации + const students = [...ls.data.body.students] + const addStudentWithDelay = (index) => { + if (index < students.length) { + setAnimatedStudents(prev => [...prev, {...students[index], isNew: true}]) + + // Для следующего студента + setTimeout(() => { + addStudentWithDelay(index + 1) + }, 100) // Уменьшенная задержка для более плавной анимации + } + } + + // Запускаем процесс добавления с небольшой задержкой для лучшего UX + setTimeout(() => { + addStudentWithDelay(0) + }, 300) + } + }, [ls.data?.body?.students]) + + // Эффект для сброса флага "новизны" студентов + useEffect(() => { + if (animatedStudents.length > 0) { + const timeoutId = setTimeout(() => { + setAnimatedStudents(students => + students.map(student => ({...student, isNew: false})) + ) + }, 2000) + + return () => clearTimeout(timeoutId) + } + }, [animatedStudents]) if (acc.isLoading) { return ( @@ -42,13 +89,30 @@ const UserPage = () => { } return ( - <Container> - {acc.isLoading && <h1>{t('journal.pl.common.sending')}</h1>} - {acc.isSuccess && <h1>{t('journal.pl.common.success')}</h1>} + <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"> + <Alert status="warning" borderRadius="lg"> <AlertIcon /> {(acc as any).error?.data?.body?.errorMessage === 'Code is expired' ? ( @@ -60,31 +124,106 @@ const UserPage = () => { </Box> )} - <Box mb={6}> - <Text fontSize={18} fontWeight={600} as="h1" mt="4" mb="3"> - {t('journal.pl.lesson.topicTitle')} {ls.data?.body?.name} - </Text> - - <span>{dayjs(ls.data?.body?.date).format(t('journal.pl.lesson.dateFormat'))}</span> - </Box> - - <Box - as="ul" - display="flex" - flexWrap="wrap" - justifyContent="center" - gap={3} + <motion.div + initial={{ opacity: 0 }} + animate={{ opacity: 1 }} + transition={{ duration: 0.4 }} > - {ls.data?.body?.students?.map((student) => ( - <UserCard - width="40%" - wrapperAS="li" - key={student.sub} - student={student} - present - /> - ))} - </Box> + <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"}> + {dayjs(ls.data?.body?.date).format(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 }} + 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} + /> + </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> ) } diff --git a/stubs/mocks/lessons/access-code/create/success.json b/stubs/mocks/lessons/access-code/create/success.json index 0f3ec54..8a51377 100644 --- a/stubs/mocks/lessons/access-code/create/success.json +++ b/stubs/mocks/lessons/access-code/create/success.json @@ -7,19 +7,25 @@ "name": "ВВОДНАЯ ПО JS.ПРИМЕНЕНИЕ И СПОСОБЫ ПОДКЛЮЧЕНИЯ НА СТРАНИЦЕ. LET, CONST. БАЗОВЫЕ ТИПЫ ДАННЫХ, ПРИВЕДЕНИЕ ТИПОВ. ПЕРЕМЕННЫЕ, ОБЛАСТЬ ВИДИМОСТИ ПЕРЕМЕННЫХ", "students": [ { - "sub": "f62905b1-e223-40ca-910f-c8d84c6137c1", - "email_verified": true, - "gravatar": "true", - "name": "Александр Примаков", - "groups": [ - "/inno-staff", - "/microfrontend-admin-user" - ], - "preferred_username": "primakov", - "given_name": "Александр", - "family_name": "Примаков", - "email": "primakovpro@gmail.com" - } + "sub": "fcde3f22-d9ba-412a-a572-c59e515a290f", + "email_verified": true, + "name": "Мария Капитанова", + "preferred_username": "maryaKapitan@gmail.com", + "given_name": "Мария", + "family_name": "Капитанова", + "email": "maryaKapitan@gmail.com", + "picture": "https://lh3.googleusercontent.com/a/ACg8ocJgIjjOFD2YUSyRF5kH4jaysE6X5p-kq0Cg0CFncfMi=s96-c" + }, + { + "sub": "8555885b-715c-4dee-a7c5-9563a6a05211", + "email_verified": true, + "name": "Евгения Жужова", + "preferred_username": "zhuzhova@gmail.com", + "given_name": "Евгения", + "family_name": "Жужова", + "email": "zhuzhova@gmail.com", + "picture": "https://lh3.googleusercontent.com/a/ACg8ocJUtJBAVBm642AxoGpMDDMV8CPu3MEoLjU3hmO7oisG=s96-c" + } ], "date": "2024-02-28T20:37:00.057Z", "created": "2024-02-28T20:37:00.057Z",