Обновлены стили компонента UserCard для улучшения визуального восприятия и добавлены анимации при наведении. Реализована поддержка отображения недавно присутствующих студентов с помощью анимации. Обновлен компонент LessonDetail для отслеживания новых студентов и их анимации при появлении. Улучшены стили списков студентов для лучшей адаптивности и пользовательского опыта.

This commit is contained in:
Primakov Alexandr Alexandrovich 2025-03-23 21:45:16 +03:00
parent 570ae4b171
commit 3d383f2e25
6 changed files with 497 additions and 131 deletions

View File

@ -1,26 +1,96 @@
import styled from '@emotion/styled' import styled from '@emotion/styled'
import { css, keyframes } from '@emotion/react' import { css, keyframes } from '@emotion/react'
export const Avatar = styled.img` // Правильное определение анимации с помощью keyframes
width: 96px; const fadeIn = keyframes`
height: 96px; from {
margin: 0 auto; opacity: 0;
border-radius: 6px; }
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 }>` export const Wrapper = styled.div<{ warn?: boolean; width?: string | number }>`
list-style: none; 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; position: relative;
width: 180px; border-radius: 12px;
min-height: 190px; width: 100%;
max-height: 200px; aspect-ratio: 1;
margin-right: 12px; overflow: hidden;
padding-bottom: 22px; 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 }) =>
width width
? css` ? css`
@ -31,35 +101,36 @@ export const Wrapper = styled.div<{ warn?: boolean; width?: string | number }>`
${(props) => ${(props) =>
props.warn props.warn
? css` ? css`
background-color: var(--chakra-colors-blackAlpha-800);
opacity: 0.7; 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` export const AddMissedButton = styled.button`
position: absolute; position: absolute;
bottom: 8px; bottom: 8px;
right: 12px; right: 8px;
border: none; border: none;
background-color: transparent; background-color: var(--chakra-colors-blue-500);
opacity: 0.2; color: white;
color: inherit; 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; cursor: pointer;
opacity: 1; opacity: 1;
transform: scale(1.1);
}
.chakra-ui-dark & {
background-color: var(--chakra-colors-blue-400);
} }
` `

View File

@ -1,10 +1,11 @@
import React from 'react' import React from 'react'
import { sha256 } from 'js-sha256' 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 { User } from '../../__data__/model'
import { AddMissedButton, Avatar, Wrapper } from './style' import { AddMissedButton, Avatar, Wrapper, NameOverlay } from './style'
export function getGravatarURL(email, user) { export function getGravatarURL(email, user) {
if (!email) return void 0 if (!email) return void 0
@ -17,15 +18,17 @@ export function getGravatarURL(email, user) {
export const UserCard = ({ export const UserCard = ({
student, student,
present, present,
onAddUser, onAddUser = undefined,
wrapperAS, wrapperAS = 'div',
width width,
recentlyPresent = false
}: { }: {
student: User student: User
present: boolean present: boolean
width?: string | number width?: string | number
onAddUser?: (user: User) => void onAddUser?: (user: User) => void
wrapperAS?: React.ElementType<any, keyof React.JSX.IntrinsicElements>; wrapperAS?: React.ElementType<any, keyof React.JSX.IntrinsicElements>;
recentlyPresent?: boolean
}) => { }) => {
const { colorMode } = useColorMode(); const { colorMode } = useColorMode();
@ -34,25 +37,22 @@ export const UserCard = ({
warn={!present} warn={!present}
as={wrapperAS} as={wrapperAS}
width={width} width={width}
className={!present ? 'warn' : ''} className={!present ? 'warn' : recentlyPresent ? 'recent' : ''}
> >
<Avatar src={student.picture || getGravatarURL(student.email, null)} /> <Avatar src={student.picture || getGravatarURL(student.email, null)} alt={student.name || student.preferred_username} />
<p style={{ <NameOverlay>
marginTop: 6, {student.name || student.preferred_username}
color: colorMode === 'light' ? 'inherit' : 'var(--chakra-colors-gray-100)' {present && (
}}> <Box as="span" ml={2} display="inline-block" color={recentlyPresent ? "green.100" : "green.300"}>
{student.name || student.preferred_username}{' '} <CheckCircleIcon boxSize={3} />
</p> </Box>
)}
</NameOverlay>
{onAddUser && !present && ( {onAddUser && !present && (
<AddMissedButton onClick={() => onAddUser(student)}> <AddMissedButton onClick={() => onAddUser(student)} aria-label="Отметить присутствие">
add <AddIcon boxSize={3} />
</AddMissedButton> </AddMissedButton>
)} )}
</Wrapper> </Wrapper>
) )
} }
UserCard.defaultProps = {
wrapperAS: 'div',
onAddUser: void 0,
}

View File

@ -4,6 +4,7 @@ import dayjs from 'dayjs'
import QRCode from 'qrcode' import QRCode from 'qrcode'
import { sha256 } from 'js-sha256' import { sha256 } from 'js-sha256'
import { getConfigValue, getNavigationValue } from '@brojs/cli' import { getConfigValue, getNavigationValue } from '@brojs/cli'
import { motion, AnimatePresence } from 'framer-motion'
import { import {
Box, Box,
Breadcrumb, Breadcrumb,
@ -13,6 +14,7 @@ import {
VStack, VStack,
Heading, Heading,
Stack, Stack,
useColorMode,
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@ -42,6 +44,10 @@ const LessonDetail = () => {
const canvRef = useRef(null) const canvRef = useRef(null)
const user = useAppSelector((s) => s.user) const user = useAppSelector((s) => s.user)
const { t } = useTranslation() const { t } = useTranslation()
const { colorMode } = useColorMode()
// Создаем ref для отслеживания ранее присутствовавших студентов
const prevPresentStudentsRef = useRef(new Set<string>())
const { const {
isFetching, isFetching,
@ -64,6 +70,20 @@ const LessonDetail = () => {
[accessCode, lessonId], [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(() => { useEffect(() => {
if (manualAddRqst.isSuccess) { if (manualAddRqst.isSuccess) {
refetch() refetch()
@ -118,13 +138,17 @@ const LessonDetail = () => {
}, [isFetching, isSuccess, userUrl]) }, [isFetching, isSuccess, userUrl])
const studentsArr = useMemo(() => { const studentsArr = useMemo(() => {
let allStudents: (User & { present?: boolean })[] = [ let allStudents: (User & { present?: boolean; recentlyPresent?: boolean })[] = [
...(AllStudents.data?.body || []), ...(AllStudents.data?.body || []),
].map((st) => ({ ...st, present: false })) ].map((st) => ({ ...st, present: false, recentlyPresent: false }))
let presentStudents: (User & { present?: boolean })[] = [ let presentStudents: (User & { present?: boolean })[] = [
...(accessCode?.body.lesson.students || []), ...(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) { while (presentStudents.length) {
const student = presentStudents.pop() const student = presentStudents.pop()
@ -132,13 +156,18 @@ const LessonDetail = () => {
if (present) { if (present) {
present.present = true present.present = true
present.recentlyPresent = newlyPresent.includes(student.sub)
} else { } 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)) return allStudents.sort((a, b) => (a.present ? -1 : 1))
}, [accessCode?.body, AllStudents.data]) }, [accessCode?.body, AllStudents.data, prevPresentStudentsRef.current])
return ( return (
<> <>
@ -170,32 +199,76 @@ const LessonDetail = () => {
{t('journal.pl.lesson.topicTitle')} {t('journal.pl.lesson.topicTitle')}
</Heading> </Heading>
<Box as="span">{accessCode?.body?.lesson?.name}</Box> <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> </VStack>
<Stack spacing="8" direction={{ base: "column", md: "row" }}> <Stack spacing="8" direction={{ base: "column", md: "row" }} mt={6}>
<Box flexShrink={0} alignSelf="center"> <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}> <a href={userUrl}>
<QRCanvas ref={canvRef} /> <QRCanvas ref={canvRef} />
</a> </a>
</Box> </Box>
<StudentList> <Box
{isTeacher(user) && studentsArr.map((student) => ( flex={1}
<UserCard p={4}
wrapperAS="li" borderRadius="xl"
key={student.sub} bg={colorMode === "light" ? "gray.50" : "gray.700"}
student={student} boxShadow="md"
present={student.present} >
onAddUser={(user: User) => manualAdd({ lessonId, user })} <StudentList>
/> {isTeacher(user) && (
))} <AnimatePresence initial={false}>
</StudentList> {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> </Stack>
</Container> </Container>
</> </>

View File

@ -16,19 +16,96 @@ const reveal = keyframes`
` `
export const StudentList = styled.ul` export const StudentList = styled.ul`
padding-left: 0px; padding: 0;
height: 600px; list-style: none;
justify-content: space-evenly; display: grid;
padding-right: 20px; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
display: flex; gap: 16px;
flex-direction: row; width: 100%;
flex-wrap: wrap; max-height: 600px;
gap: 8px; overflow-y: auto;
@media (max-width: 768px) { @media (max-width: 768px) {
height: auto; gap: 12px;
max-height: 600px; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
padding-right: 0; }
/* Стили для 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);
} }
` `

View File

@ -1,6 +1,7 @@
import React from 'react' import React, { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom' import { useParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { motion, AnimatePresence } from 'framer-motion'
import { api } from '../__data__/api/api' import { api } from '../__data__/api/api'
import dayjs from 'dayjs' import dayjs from 'dayjs'
@ -12,18 +13,64 @@ import {
Container, Container,
Spinner, Spinner,
Text, Text,
Heading,
Badge,
Flex,
useColorMode,
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { UserCard } from '../components/user-card' import { UserCard } from '../components/user-card'
import { StudentListView } from './style'
const UserPage = () => { const UserPage = () => {
const { lessonId, accessId } = useParams() const { lessonId, accessId } = useParams()
const { t } = useTranslation() const { t } = useTranslation()
const { colorMode } = useColorMode()
const acc = api.useGetAccessQuery({ accessCode: accessId }) const acc = api.useGetAccessQuery({ accessCode: accessId })
const [animatedStudents, setAnimatedStudents] = useState([])
const ls = api.useLessonByIdQuery(lessonId, { const ls = api.useLessonByIdQuery(lessonId, {
pollingInterval: 1000, pollingInterval: 1000,
skipPollingIfUnfocused: true, 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) { if (acc.isLoading) {
return ( return (
@ -42,13 +89,30 @@ const UserPage = () => {
} }
return ( return (
<Container> <Container maxW="container.lg" pt={4}>
{acc.isLoading && <h1>{t('journal.pl.common.sending')}</h1>} {acc.isLoading && (
{acc.isSuccess && <h1>{t('journal.pl.common.success')}</h1>} <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 && ( {acc.error && (
<Box mb="6" mt="2"> <Box mb="6" mt="2">
<Alert status="warning"> <Alert status="warning" borderRadius="lg">
<AlertIcon /> <AlertIcon />
{(acc as any).error?.data?.body?.errorMessage === {(acc as any).error?.data?.body?.errorMessage ===
'Code is expired' ? ( 'Code is expired' ? (
@ -60,31 +124,106 @@ const UserPage = () => {
</Box> </Box>
)} )}
<Box mb={6}> <motion.div
<Text fontSize={18} fontWeight={600} as="h1" mt="4" mb="3"> initial={{ opacity: 0 }}
{t('journal.pl.lesson.topicTitle')} {ls.data?.body?.name} animate={{ opacity: 1 }}
</Text> transition={{ duration: 0.4 }}
<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}
> >
{ls.data?.body?.students?.map((student) => ( <Box
<UserCard mb={6}
width="40%" p={5}
wrapperAS="li" borderRadius="xl"
key={student.sub} bg={colorMode === "light" ? "gray.50" : "gray.700"}
student={student} boxShadow="md"
present >
/> <Heading fontSize="xl" fontWeight={600} mb={2}>
))} {t('journal.pl.lesson.topicTitle')}
</Box> <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> </Container>
) )
} }

View File

@ -7,19 +7,25 @@
"name": "ВВОДНАЯ ПО JS.ПРИМЕНЕНИЕ И СПОСОБЫ ПОДКЛЮЧЕНИЯ НА СТРАНИЦЕ. LET, CONST. БАЗОВЫЕ ТИПЫ ДАННЫХ, ПРИВЕДЕНИЕ ТИПОВ. ПЕРЕМЕННЫЕ, ОБЛАСТЬ ВИДИМОСТИ ПЕРЕМЕННЫХ", "name": "ВВОДНАЯ ПО JS.ПРИМЕНЕНИЕ И СПОСОБЫ ПОДКЛЮЧЕНИЯ НА СТРАНИЦЕ. LET, CONST. БАЗОВЫЕ ТИПЫ ДАННЫХ, ПРИВЕДЕНИЕ ТИПОВ. ПЕРЕМЕННЫЕ, ОБЛАСТЬ ВИДИМОСТИ ПЕРЕМЕННЫХ",
"students": [ "students": [
{ {
"sub": "f62905b1-e223-40ca-910f-c8d84c6137c1", "sub": "fcde3f22-d9ba-412a-a572-c59e515a290f",
"email_verified": true, "email_verified": true,
"gravatar": "true", "name": "Мария Капитанова",
"name": "Александр Примаков", "preferred_username": "maryaKapitan@gmail.com",
"groups": [ "given_name": "Мария",
"/inno-staff", "family_name": "Капитанова",
"/microfrontend-admin-user" "email": "maryaKapitan@gmail.com",
], "picture": "https://lh3.googleusercontent.com/a/ACg8ocJgIjjOFD2YUSyRF5kH4jaysE6X5p-kq0Cg0CFncfMi=s96-c"
"preferred_username": "primakov", },
"given_name": "Александр", {
"family_name": "Примаков", "sub": "8555885b-715c-4dee-a7c5-9563a6a05211",
"email": "primakovpro@gmail.com" "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", "date": "2024-02-28T20:37:00.057Z",
"created": "2024-02-28T20:37:00.057Z", "created": "2024-02-28T20:37:00.057Z",