Compare commits
15 Commits
Author | SHA1 | Date | |
---|---|---|---|
183e3826be | |||
1ec4bc081e | |||
|
870ac5348b | ||
|
d648a181c3 | ||
|
56a04dbe14 | ||
|
5a92ff2bee | ||
|
543796740b | ||
|
452d451224 | ||
|
23c943f05d | ||
|
c87413eb2c | ||
|
245d56410d | ||
|
424013c570 | ||
|
8a66b96599 | ||
|
32aad802b9 | ||
|
03a6172d91 |
@ -210,5 +210,33 @@
|
|||||||
"journal.pl.overview.new": "new",
|
"journal.pl.overview.new": "new",
|
||||||
"journal.pl.overview.pastLessonsStats": "Statistics of past lessons",
|
"journal.pl.overview.pastLessonsStats": "Statistics of past lessons",
|
||||||
"journal.pl.overview.dayOfWeekHelp": "Only statistics for completed lessons are shown",
|
"journal.pl.overview.dayOfWeekHelp": "Only statistics for completed lessons are shown",
|
||||||
"journal.pl.overview.attendanceHelp": "Attendance is calculated based on past lessons only"
|
"journal.pl.overview.attendanceHelp": "Attendance is calculated based on past lessons only",
|
||||||
|
"journal.pl.today": "Today",
|
||||||
|
"journal.pl.tomorrow": "Tomorrow",
|
||||||
|
"journal.pl.dayAfterTomorrow": "Day after tomorrow",
|
||||||
|
"journal.pl.days.morning": "Morning",
|
||||||
|
"journal.pl.days.day": "Day",
|
||||||
|
"journal.pl.days.evening": "Evening",
|
||||||
|
"journal.pl.lesson.form.selectTime": "Select time",
|
||||||
|
"journal.pl.lesson.existingLessonHint": "There is already a lesson on this day",
|
||||||
|
"journal.pl.lesson.form.selectDate": "Select date",
|
||||||
|
"journal.pl.days.shortMonday": "Mo",
|
||||||
|
"journal.pl.days.shortTuesday": "Tu",
|
||||||
|
"journal.pl.days.shortWednesday": "We",
|
||||||
|
"journal.pl.days.shortThursday": "Th",
|
||||||
|
"journal.pl.days.shortFriday": "Fr",
|
||||||
|
"journal.pl.days.shortSaturday": "Sa",
|
||||||
|
"journal.pl.days.shortSunday": "Su",
|
||||||
|
"journal.pl.months.january": "January",
|
||||||
|
"journal.pl.months.february": "February",
|
||||||
|
"journal.pl.months.march": "March",
|
||||||
|
"journal.pl.months.april": "April",
|
||||||
|
"journal.pl.months.may": "May",
|
||||||
|
"journal.pl.months.june": "June",
|
||||||
|
"journal.pl.months.july": "July",
|
||||||
|
"journal.pl.months.august": "August",
|
||||||
|
"journal.pl.months.september": "September",
|
||||||
|
"journal.pl.months.october": "October",
|
||||||
|
"journal.pl.months.november": "November",
|
||||||
|
"journal.pl.months.december": "December"
|
||||||
}
|
}
|
@ -207,5 +207,33 @@
|
|||||||
"journal.pl.overview.new": "новых",
|
"journal.pl.overview.new": "новых",
|
||||||
"journal.pl.overview.pastLessonsStats": "Статистика проведённых занятий",
|
"journal.pl.overview.pastLessonsStats": "Статистика проведённых занятий",
|
||||||
"journal.pl.overview.dayOfWeekHelp": "Показана статистика только состоявшихся занятий",
|
"journal.pl.overview.dayOfWeekHelp": "Показана статистика только состоявшихся занятий",
|
||||||
"journal.pl.overview.attendanceHelp": "Посещаемость рассчитана только по прошедшим занятиям"
|
"journal.pl.overview.attendanceHelp": "Посещаемость рассчитана только по прошедшим занятиям",
|
||||||
|
"journal.pl.today": "Сегодня",
|
||||||
|
"journal.pl.tomorrow": "Завтра",
|
||||||
|
"journal.pl.dayAfterTomorrow": "Послезавтра",
|
||||||
|
"journal.pl.days.morning": "Утро",
|
||||||
|
"journal.pl.days.day": "День",
|
||||||
|
"journal.pl.days.evening": "Вечер",
|
||||||
|
"journal.pl.lesson.form.selectTime": "Выберите время",
|
||||||
|
"journal.pl.lesson.existingLessonHint": "В этот день уже есть лекция",
|
||||||
|
"journal.pl.lesson.form.selectDate": "Выберите дату",
|
||||||
|
"journal.pl.days.shortMonday": "Пн",
|
||||||
|
"journal.pl.days.shortTuesday": "Вт",
|
||||||
|
"journal.pl.days.shortWednesday": "Ср",
|
||||||
|
"journal.pl.days.shortThursday": "Чт",
|
||||||
|
"journal.pl.days.shortFriday": "Пт",
|
||||||
|
"journal.pl.days.shortSaturday": "Сб",
|
||||||
|
"journal.pl.days.shortSunday": "Вс",
|
||||||
|
"journal.pl.months.january": "Январь",
|
||||||
|
"journal.pl.months.february": "Февраль",
|
||||||
|
"journal.pl.months.march": "Март",
|
||||||
|
"journal.pl.months.april": "Апрель",
|
||||||
|
"journal.pl.months.may": "Май",
|
||||||
|
"journal.pl.months.june": "Июнь",
|
||||||
|
"journal.pl.months.july": "Июль",
|
||||||
|
"journal.pl.months.august": "Август",
|
||||||
|
"journal.pl.months.september": "Сентябрь",
|
||||||
|
"journal.pl.months.october": "Октябрь",
|
||||||
|
"journal.pl.months.november": "Ноябрь",
|
||||||
|
"journal.pl.months.december": "Декабрь"
|
||||||
}
|
}
|
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "journal.pl",
|
"name": "journal.pl",
|
||||||
"version": "3.15.0",
|
"version": "3.16.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "journal.pl",
|
"name": "journal.pl",
|
||||||
"version": "3.15.0",
|
"version": "3.16.5",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@brojs/cli": "^1.8.4",
|
"@brojs/cli": "^1.8.4",
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "journal.pl",
|
"name": "journal.pl",
|
||||||
"version": "3.15.0",
|
"version": "3.16.5",
|
||||||
"description": "bro-js platform journal ui repo",
|
"description": "bro-js platform journal ui repo",
|
||||||
"main": "./src/index.tsx",
|
"main": "./src/index.tsx",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
@ -59,7 +59,7 @@ export interface Lesson {
|
|||||||
id: string;
|
id: string;
|
||||||
_id: string;
|
_id: string;
|
||||||
name: string;
|
name: string;
|
||||||
reactions: Reaction[];
|
studentReactions: Reaction[];
|
||||||
students: User[];
|
students: User[];
|
||||||
teachers: Teacher[];
|
teachers: Teacher[];
|
||||||
date: string;
|
date: string;
|
||||||
|
@ -114,30 +114,3 @@ export const Wrapper = styled.div<{ warn?: boolean; width?: string | number; pos
|
|||||||
: ''}
|
: ''}
|
||||||
`
|
`
|
||||||
|
|
||||||
export const AddMissedButton = styled.button`
|
|
||||||
position: absolute;
|
|
||||||
bottom: 8px;
|
|
||||||
right: 8px;
|
|
||||||
border: none;
|
|
||||||
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 {
|
|
||||||
cursor: pointer;
|
|
||||||
opacity: 1;
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chakra-ui-dark & {
|
|
||||||
background-color: var(--chakra-colors-blue-400);
|
|
||||||
}
|
|
||||||
`
|
|
@ -6,9 +6,9 @@ import { CheckCircleIcon, AddIcon } from '@chakra-ui/icons'
|
|||||||
import { motion, AnimatePresence } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import { User, Reaction } from '../../__data__/model'
|
import { Reaction, User } from '../../__data__/model'
|
||||||
|
|
||||||
import { AddMissedButton, Avatar, Wrapper, NameOverlay } from './style'
|
import { Avatar, Wrapper, NameOverlay } from './style'
|
||||||
|
|
||||||
// Map of reaction types to emojis
|
// Map of reaction types to emojis
|
||||||
const REACTION_EMOJIS = {
|
const REACTION_EMOJIS = {
|
||||||
@ -30,62 +30,47 @@ export function getGravatarURL(email, user) {
|
|||||||
export const UserCard = ({
|
export const UserCard = ({
|
||||||
student,
|
student,
|
||||||
present,
|
present,
|
||||||
onAddUser = undefined,
|
|
||||||
wrapperAS = 'div',
|
wrapperAS = 'div',
|
||||||
width,
|
width,
|
||||||
recentlyPresent = false,
|
recentlyPresent = false,
|
||||||
reactions = []
|
reaction
|
||||||
}: {
|
}: {
|
||||||
student: User
|
student: User
|
||||||
present: boolean
|
present: boolean
|
||||||
width?: string | number
|
width?: string | number
|
||||||
onAddUser?: (user: User) => void
|
wrapperAS?: React.ElementType<any, keyof React.JSX.IntrinsicElements>
|
||||||
wrapperAS?: React.ElementType<any, keyof React.JSX.IntrinsicElements>;
|
|
||||||
recentlyPresent?: boolean
|
recentlyPresent?: boolean
|
||||||
reactions?: Reaction[]
|
reaction?: Reaction
|
||||||
}) => {
|
}) => {
|
||||||
const { colorMode } = useColorMode();
|
const { colorMode } = useColorMode();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [imageError, setImageError] = useState(false);
|
const [imageError, setImageError] = useState(false);
|
||||||
const [visibleReactions, setVisibleReactions] = useState<Reaction[]>([]);
|
const [showReaction, setShowReaction] = useState(false);
|
||||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
// Filter reactions to only show this student's reactions
|
// Обрабатываем изменение реакции
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const studentReactions = reactions.filter(r => r.sub === student.sub);
|
if (reaction) {
|
||||||
|
setShowReaction(true);
|
||||||
if (studentReactions.length > 0) {
|
|
||||||
// Check for new reactions
|
|
||||||
const newReactions = studentReactions.filter(
|
|
||||||
newReaction => !visibleReactions.some(
|
|
||||||
existingReaction => existingReaction._id === newReaction._id
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (newReactions.length > 0) {
|
// Очищаем предыдущий таймер если он есть
|
||||||
// If there are new reactions, add them to visible reactions
|
if (timeoutRef.current) {
|
||||||
setVisibleReactions(prevReactions => [...prevReactions, ...newReactions]);
|
clearTimeout(timeoutRef.current);
|
||||||
|
|
||||||
// Clear any existing timeout
|
|
||||||
if (timeoutRef.current) {
|
|
||||||
clearTimeout(timeoutRef.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set a new timeout
|
|
||||||
timeoutRef.current = setTimeout(() => {
|
|
||||||
setVisibleReactions([]);
|
|
||||||
timeoutRef.current = null;
|
|
||||||
}, 3000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Устанавливаем новый таймер
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
setShowReaction(false);
|
||||||
|
timeoutRef.current = null;
|
||||||
|
}, 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up on unmount
|
|
||||||
return () => {
|
return () => {
|
||||||
if (timeoutRef.current) {
|
if (timeoutRef.current) {
|
||||||
clearTimeout(timeoutRef.current);
|
clearTimeout(timeoutRef.current);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [reactions, student.sub, visibleReactions]);
|
}, [reaction]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Wrapper
|
<Wrapper
|
||||||
@ -111,28 +96,21 @@ export const UserCard = ({
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</NameOverlay>
|
</NameOverlay>
|
||||||
{onAddUser && !present && (
|
|
||||||
<AddMissedButton onClick={() => onAddUser(student)} aria-label={t('journal.pl.common.add')}>
|
|
||||||
<AddIcon boxSize={3} />
|
|
||||||
</AddMissedButton>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Student reactions animation */}
|
|
||||||
|
{/* Анимация реакции */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{visibleReactions.map((reaction, index) => (
|
{showReaction && reaction && (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={reaction._id || index}
|
key={reaction._id}
|
||||||
initial={{ opacity: 0, scale: 0.5, x: 0, y: 0 }}
|
initial={{ opacity: 0, scale: 0.5, y: 0 }}
|
||||||
animate={{ opacity: 1, scale: 1, x: 0, y: 0 }}
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, scale: 0.5, y: -20 }}
|
exit={{ opacity: 0, scale: 0.5, y: -20 }}
|
||||||
transition={{
|
transition={{ duration: 0.5 }}
|
||||||
duration: 0.5,
|
|
||||||
delay: index * 0.1
|
|
||||||
}}
|
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: '10px', // Position at the top
|
top: '10px',
|
||||||
right: '10px', // Position at the right
|
right: '10px',
|
||||||
zIndex: 10,
|
zIndex: 10,
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@ -146,17 +124,17 @@ export const UserCard = ({
|
|||||||
title={t(`journal.pl.reactions.${reaction.reaction}`)}
|
title={t(`journal.pl.reactions.${reaction.reaction}`)}
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
fontSize="3xl" // Increased size
|
fontSize="3xl"
|
||||||
sx={{
|
sx={{
|
||||||
filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.3))',
|
filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.3))',
|
||||||
transform: 'scale(1.2)', // Additional scaling
|
transform: 'scale(1.2)',
|
||||||
display: 'flex'
|
display: 'flex'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{REACTION_EMOJIS[reaction.reaction] || reaction.reaction}
|
{REACTION_EMOJIS[reaction.reaction] || reaction.reaction}
|
||||||
</Text>
|
</Text>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</Wrapper>
|
</Wrapper>
|
||||||
)
|
)
|
||||||
|
@ -4,6 +4,7 @@ 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 { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import { AddIcon } from '@chakra-ui/icons'
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Container,
|
Container,
|
||||||
@ -22,6 +23,7 @@ import { formatDate } from '../utils/dayjs-config'
|
|||||||
import { useSetBreadcrumbs } from '../components'
|
import { useSetBreadcrumbs } from '../components'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
AddMissedButton,
|
||||||
QRCanvas,
|
QRCanvas,
|
||||||
StudentList,
|
StudentList,
|
||||||
} from './style'
|
} from './style'
|
||||||
@ -43,11 +45,11 @@ const LessonDetail = () => {
|
|||||||
const user = useAppSelector((s) => s.user)
|
const user = useAppSelector((s) => s.user)
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { colorMode } = useColorMode()
|
const { colorMode } = useColorMode()
|
||||||
|
|
||||||
// Получаем данные о курсе и уроке
|
// Получаем данные о курсе и уроке
|
||||||
const { data: courseData } = api.useGetCourseByIdQuery(courseId)
|
const { data: courseData } = api.useGetCourseByIdQuery(courseId)
|
||||||
const { data: lessonData } = api.useLessonByIdQuery(lessonId)
|
const { data: lessonData } = api.useLessonByIdQuery(lessonId)
|
||||||
|
|
||||||
// Устанавливаем хлебные крошки
|
// Устанавливаем хлебные крошки
|
||||||
useSetBreadcrumbs([
|
useSetBreadcrumbs([
|
||||||
{
|
{
|
||||||
@ -63,10 +65,10 @@ const LessonDetail = () => {
|
|||||||
isCurrentPage: true
|
isCurrentPage: true
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
|
||||||
// Создаем ref для отслеживания ранее присутствовавших студентов
|
// Создаем ref для отслеживания ранее присутствовавших студентов
|
||||||
const prevPresentStudentsRef = useRef(new Set<string>())
|
const prevPresentStudentsRef = useRef(new Set<string>())
|
||||||
|
|
||||||
// Добавляем состояние для отслеживания пульсации
|
// Добавляем состояние для отслеживания пульсации
|
||||||
const [isPulsing, setIsPulsing] = useState(false)
|
const [isPulsing, setIsPulsing] = useState(false)
|
||||||
// Отслеживаем предыдущее количество студентов
|
// Отслеживаем предыдущее количество студентов
|
||||||
@ -75,7 +77,7 @@ const LessonDetail = () => {
|
|||||||
const prevReactionsRef = useRef<Record<string, Reaction[]>>({})
|
const prevReactionsRef = useRef<Record<string, Reaction[]>>({})
|
||||||
// Храним актуальные реакции студентов
|
// Храним актуальные реакции студентов
|
||||||
const [studentReactions, setStudentReactions] = useState<Record<string, Reaction[]>>({})
|
const [studentReactions, setStudentReactions] = useState<Record<string, Reaction[]>>({})
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isFetching,
|
isFetching,
|
||||||
data: accessCode,
|
data: accessCode,
|
||||||
@ -101,7 +103,7 @@ const LessonDetail = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (accessCode?.body) {
|
if (accessCode?.body) {
|
||||||
const currentPresent = new Set(accessCode.body.lesson.students.map(s => s.sub))
|
const currentPresent = new Set(accessCode.body.lesson.students.map(s => s.sub))
|
||||||
|
|
||||||
// Проверяем, изменилось ли количество студентов
|
// Проверяем, изменилось ли количество студентов
|
||||||
const currentCount = accessCode.body.lesson.students.length;
|
const currentCount = accessCode.body.lesson.students.length;
|
||||||
if (prevStudentCountRef.current !== currentCount && prevStudentCountRef.current > 0) {
|
if (prevStudentCountRef.current !== currentCount && prevStudentCountRef.current > 0) {
|
||||||
@ -112,56 +114,45 @@ const LessonDetail = () => {
|
|||||||
setIsPulsing(false);
|
setIsPulsing(false);
|
||||||
}, 1500);
|
}, 1500);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обновляем предыдущее количество
|
// Обновляем предыдущее количество
|
||||||
prevStudentCountRef.current = currentCount;
|
prevStudentCountRef.current = currentCount;
|
||||||
|
|
||||||
// Очищаем флаги предыдущего состояния после задержки
|
// Очищаем флаги предыдущего состояния после задержки
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
prevPresentStudentsRef.current = currentPresent
|
prevPresentStudentsRef.current = currentPresent
|
||||||
}, 3000)
|
}, 3000)
|
||||||
|
|
||||||
return () => clearTimeout(timeoutId)
|
return () => clearTimeout(timeoutId)
|
||||||
}
|
}
|
||||||
}, [accessCode])
|
}, [accessCode])
|
||||||
|
|
||||||
// Эффект для обработки новых реакций
|
// Эффект для обработки новых реакций
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (accessCode?.body?.lesson?.reactions) {
|
if (accessCode?.body?.lesson?.studentReactions) {
|
||||||
const reactions = accessCode.body.lesson.reactions;
|
const reactions = accessCode.body.lesson.studentReactions;
|
||||||
|
|
||||||
// Группируем реакции по sub (идентификатору студента)
|
// Группируем реакции по sub (идентификатору студента)
|
||||||
const groupedReactions: Record<string, Reaction[]> = {};
|
const groupedReactions: Record<string, Reaction[]> = {};
|
||||||
|
|
||||||
reactions.forEach(reaction => {
|
reactions.forEach(reaction => {
|
||||||
if (!groupedReactions[reaction.sub]) {
|
if (!groupedReactions[reaction.sub]) {
|
||||||
groupedReactions[reaction.sub] = [];
|
groupedReactions[reaction.sub] = [];
|
||||||
}
|
}
|
||||||
|
groupedReactions[reaction.sub].push(reaction);
|
||||||
// Добавляем только новые реакции
|
|
||||||
const isNewReaction = !prevReactionsRef.current[reaction.sub]?.some(
|
|
||||||
r => r._id === reaction._id
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isNewReaction) {
|
|
||||||
groupedReactions[reaction.sub].push(reaction);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Обновляем отображаемые реакции
|
// Обновляем отображаемые реакции
|
||||||
setStudentReactions(groupedReactions);
|
setStudentReactions(groupedReactions);
|
||||||
|
|
||||||
// Обновляем предыдущие реакции
|
// Обновляем предыдущие реакции после небольшой задержки
|
||||||
prevReactionsRef.current = { ...groupedReactions };
|
const updatePrevReactionsTimeout = setTimeout(() => {
|
||||||
|
prevReactionsRef.current = groupedReactions;
|
||||||
// Сбрасываем отображаемые реакции через некоторое время
|
}, 1000);
|
||||||
const clearReactionsTimeout = setTimeout(() => {
|
|
||||||
setStudentReactions({});
|
return () => clearTimeout(updatePrevReactionsTimeout);
|
||||||
}, 5000);
|
|
||||||
|
|
||||||
return () => clearTimeout(clearReactionsTimeout);
|
|
||||||
}
|
}
|
||||||
}, [accessCode?.body?.lesson?.reactions]);
|
}, [accessCode?.body?.lesson?.studentReactions]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (manualAddRqst.isSuccess) {
|
if (manualAddRqst.isSuccess) {
|
||||||
@ -173,23 +164,23 @@ const LessonDetail = () => {
|
|||||||
if (!isFetching && isSuccess) {
|
if (!isFetching && isSuccess) {
|
||||||
const generateQRCode = () => {
|
const generateQRCode = () => {
|
||||||
if (!canvRef.current) return;
|
if (!canvRef.current) return;
|
||||||
|
|
||||||
// Получаем текущую ширину канваса, гарантируя квадратный QR-код
|
// Получаем текущую ширину канваса, гарантируя квадратный QR-код
|
||||||
const canvas = canvRef.current;
|
const canvas = canvRef.current;
|
||||||
const containerWidth = canvas.clientWidth;
|
const containerWidth = canvas.clientWidth;
|
||||||
|
|
||||||
// Очищаем canvas перед новой генерацией
|
// Очищаем canvas перед новой генерацией
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
// Устанавливаем одинаковые размеры для ширины и высоты (1:1)
|
// Устанавливаем одинаковые размеры для ширины и высоты (1:1)
|
||||||
canvas.width = containerWidth;
|
canvas.width = containerWidth;
|
||||||
canvas.height = containerWidth;
|
canvas.height = containerWidth;
|
||||||
|
|
||||||
QRCode.toCanvas(
|
QRCode.toCanvas(
|
||||||
canvas,
|
canvas,
|
||||||
userUrl,
|
userUrl,
|
||||||
{
|
{
|
||||||
width: containerWidth,
|
width: containerWidth,
|
||||||
margin: 1 // Небольшой отступ для лучшей читаемости
|
margin: 1 // Небольшой отступ для лучшей читаемости
|
||||||
},
|
},
|
||||||
@ -199,17 +190,17 @@ const LessonDetail = () => {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Генерируем QR-код
|
// Генерируем QR-код
|
||||||
generateQRCode();
|
generateQRCode();
|
||||||
|
|
||||||
// Перегенерируем при изменении размера окна
|
// Перегенерируем при изменении размера окна
|
||||||
const handleResize = () => {
|
const handleResize = () => {
|
||||||
generateQRCode();
|
generateQRCode();
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('resize', handleResize);
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('resize', handleResize);
|
window.removeEventListener('resize', handleResize);
|
||||||
};
|
};
|
||||||
@ -237,10 +228,10 @@ const LessonDetail = () => {
|
|||||||
present.present = true
|
present.present = true
|
||||||
present.recentlyPresent = newlyPresent.includes(student.sub)
|
present.recentlyPresent = newlyPresent.includes(student.sub)
|
||||||
} else {
|
} else {
|
||||||
allStudents.push({
|
allStudents.push({
|
||||||
...student,
|
...student,
|
||||||
present: true,
|
present: true,
|
||||||
recentlyPresent: newlyPresent.includes(student.sub)
|
recentlyPresent: newlyPresent.includes(student.sub)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -252,7 +243,7 @@ const LessonDetail = () => {
|
|||||||
// Функция для определения цвета на основе посещаемости
|
// Функция для определения цвета на основе посещаемости
|
||||||
const getAttendanceColor = (attendance: number, total: number) => {
|
const getAttendanceColor = (attendance: number, total: number) => {
|
||||||
const percentage = total > 0 ? (attendance / total) * 100 : 0
|
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 > 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 > 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 > 40) return { bg: 'yellow.100', color: 'yellow.800', dark: { bg: 'yellow.800', color: 'yellow.100' } }
|
||||||
@ -268,74 +259,74 @@ 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>
|
||||||
|
|
||||||
</VStack>
|
</VStack>
|
||||||
<Stack spacing="8" direction={{ base: "column", md: "row" }} mt={6}>
|
<Stack spacing="8" direction={{ base: "column", md: "row" }} mt={6}>
|
||||||
<Box
|
<Box
|
||||||
flexShrink={0}
|
flexShrink={0}
|
||||||
alignSelf="flex-start"
|
alignSelf="flex-start"
|
||||||
p={4}
|
p={4}
|
||||||
borderRadius="xl"
|
borderRadius="xl"
|
||||||
bg={colorMode === "light" ? "gray.50" : "gray.700"}
|
bg={colorMode === "light" ? "gray.50" : "gray.700"}
|
||||||
boxShadow="md"
|
boxShadow="md"
|
||||||
position="sticky"
|
position="sticky"
|
||||||
top="20px"
|
top="20px"
|
||||||
zIndex="2"
|
zIndex="2"
|
||||||
><Box pb={3}>
|
><Box pb={3}>
|
||||||
{formatDate(accessCode?.body?.lesson?.date, t('journal.pl.lesson.dateFormat'))}{' '}
|
{formatDate(accessCode?.body?.lesson?.date, t('journal.pl.lesson.dateFormat'))}{' '}
|
||||||
{t('journal.pl.common.marked')} -
|
{t('journal.pl.common.marked')} -
|
||||||
{AllStudents.isSuccess && (
|
{AllStudents.isSuccess && (
|
||||||
<Box
|
<Box
|
||||||
as="span"
|
as="span"
|
||||||
px={2}
|
px={2}
|
||||||
py={1}
|
py={1}
|
||||||
ml={2}
|
ml={2}
|
||||||
borderRadius="md"
|
borderRadius="md"
|
||||||
fontWeight="bold"
|
fontWeight="bold"
|
||||||
bg={getAttendanceColor(
|
bg={getAttendanceColor(
|
||||||
accessCode?.body?.lesson?.students?.length || 0,
|
accessCode?.body?.lesson?.students?.length || 0,
|
||||||
AllStudents?.data?.body?.length || 1
|
AllStudents?.data?.body?.length || 1
|
||||||
).bg}
|
).bg}
|
||||||
color={getAttendanceColor(
|
color={getAttendanceColor(
|
||||||
accessCode?.body?.lesson?.students?.length || 0,
|
accessCode?.body?.lesson?.students?.length || 0,
|
||||||
AllStudents?.data?.body?.length || 1
|
AllStudents?.data?.body?.length || 1
|
||||||
).color}
|
).color}
|
||||||
_dark={{
|
_dark={{
|
||||||
bg: getAttendanceColor(
|
bg: getAttendanceColor(
|
||||||
accessCode?.body?.lesson?.students?.length || 0,
|
accessCode?.body?.lesson?.students?.length || 0,
|
||||||
AllStudents?.data?.body?.length || 1
|
AllStudents?.data?.body?.length || 1
|
||||||
).dark.bg,
|
).dark.bg,
|
||||||
color: getAttendanceColor(
|
color: getAttendanceColor(
|
||||||
accessCode?.body?.lesson?.students?.length || 0,
|
accessCode?.body?.lesson?.students?.length || 0,
|
||||||
AllStudents?.data?.body?.length || 1
|
AllStudents?.data?.body?.length || 1
|
||||||
).dark.color
|
).dark.color
|
||||||
}}
|
}}
|
||||||
position="relative"
|
position="relative"
|
||||||
animation={isPulsing ? "pulse 1.5s ease-in-out" : "none"}
|
animation={isPulsing ? "pulse 1.5s ease-in-out" : "none"}
|
||||||
sx={{
|
sx={{
|
||||||
'@keyframes pulse': {
|
'@keyframes pulse': {
|
||||||
'0%': { transform: 'scale(1)' },
|
'0%': { transform: 'scale(1)' },
|
||||||
'50%': { transform: 'scale(1.15)', boxShadow: '0 0 10px rgba(66, 153, 225, 0.7)' },
|
'50%': { transform: 'scale(1.15)', boxShadow: '0 0 10px rgba(66, 153, 225, 0.7)' },
|
||||||
'100%': { transform: 'scale(1)' }
|
'100%': { transform: 'scale(1)' }
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{accessCode?.body?.lesson?.students?.length} / {AllStudents?.data?.body?.length}
|
{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>
|
</Box>
|
||||||
)}
|
|
||||||
{!AllStudents.isSuccess && (
|
|
||||||
<span> {accessCode?.body?.lesson?.students?.length}</span>
|
|
||||||
)}{' '}
|
|
||||||
{t('journal.pl.common.people')}
|
|
||||||
</Box>
|
|
||||||
<a href={userUrl}>
|
<a href={userUrl}>
|
||||||
<QRCanvas ref={canvRef} />
|
<QRCanvas ref={canvRef} />
|
||||||
</a>
|
</a>
|
||||||
</Box>
|
</Box>
|
||||||
<Box
|
<Box
|
||||||
flex={1}
|
flex={1}
|
||||||
p={4}
|
p={4}
|
||||||
borderRadius="xl"
|
borderRadius="xl"
|
||||||
bg={colorMode === "light" ? "gray.50" : "gray.700"}
|
bg={colorMode === "light" ? "gray.50" : "gray.700"}
|
||||||
boxShadow="md"
|
boxShadow="md"
|
||||||
>
|
>
|
||||||
@ -345,20 +336,20 @@ const LessonDetail = () => {
|
|||||||
{studentsArr.map((student) => (
|
{studentsArr.map((student) => (
|
||||||
<motion.li
|
<motion.li
|
||||||
key={student.sub}
|
key={student.sub}
|
||||||
animate={{
|
animate={{
|
||||||
rotateY: student.present ? 0 : 180,
|
rotateY: student.present ? 0 : 180,
|
||||||
boxShadow: student.recentlyPresent
|
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(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)'
|
: '0 0 0 0 rgba(0, 0, 0, 0)'
|
||||||
}}
|
}}
|
||||||
transition={{
|
transition={{
|
||||||
rotateY: { type: "spring", stiffness: 300, damping: 20 },
|
rotateY: { type: "spring", stiffness: 300, damping: 20 },
|
||||||
boxShadow: {
|
boxShadow: {
|
||||||
repeat: student.recentlyPresent ? 3 : 0,
|
repeat: student.recentlyPresent ? 3 : 0,
|
||||||
duration: 1.5
|
duration: 1.5
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
transformStyle: "preserve-3d",
|
transformStyle: "preserve-3d",
|
||||||
perspective: "1000px",
|
perspective: "1000px",
|
||||||
aspectRatio: "1",
|
aspectRatio: "1",
|
||||||
@ -367,9 +358,9 @@ const LessonDetail = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Front side - visible when present */}
|
{/* Front side - visible when present */}
|
||||||
<Box
|
<Box
|
||||||
position="relative"
|
position="relative"
|
||||||
width="100%"
|
width="100%"
|
||||||
height="100%"
|
height="100%"
|
||||||
style={{
|
style={{
|
||||||
transformStyle: "preserve-3d"
|
transformStyle: "preserve-3d"
|
||||||
@ -392,11 +383,10 @@ const LessonDetail = () => {
|
|||||||
student={student}
|
student={student}
|
||||||
present={student.present}
|
present={student.present}
|
||||||
recentlyPresent={student.recentlyPresent}
|
recentlyPresent={student.recentlyPresent}
|
||||||
onAddUser={(user: User) => manualAdd({ lessonId, user })}
|
reaction={accessCode?.body?.lesson?.studentReactions?.find(r => r.sub === student.sub)}
|
||||||
reactions={studentReactions[student.sub] || []}
|
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Back side - visible when not present */}
|
{/* Back side - visible when not present */}
|
||||||
<Flex
|
<Flex
|
||||||
position="absolute"
|
position="absolute"
|
||||||
@ -417,7 +407,13 @@ const LessonDetail = () => {
|
|||||||
aspectRatio: "1"
|
aspectRatio: "1"
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
<AddMissedButton
|
||||||
|
onClick={() => manualAdd({ lessonId, user: student })}
|
||||||
|
aria-label={t('journal.pl.common.add')}
|
||||||
|
>
|
||||||
|
<AddIcon boxSize={3} />
|
||||||
|
</AddMissedButton>
|
||||||
|
<Box
|
||||||
position="absolute"
|
position="absolute"
|
||||||
top="0"
|
top="0"
|
||||||
left="0"
|
left="0"
|
||||||
@ -437,15 +433,15 @@ const LessonDetail = () => {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Box
|
<Box
|
||||||
position="relative"
|
position="relative"
|
||||||
textAlign="center"
|
textAlign="center"
|
||||||
zIndex="1"
|
zIndex="1"
|
||||||
>
|
>
|
||||||
<Box
|
<Box
|
||||||
width="60px"
|
width="60px"
|
||||||
height="60px"
|
height="60px"
|
||||||
mx="auto"
|
mx="auto"
|
||||||
mb={2}
|
mb={2}
|
||||||
sx={{
|
sx={{
|
||||||
animation: "float 3s ease-in-out infinite",
|
animation: "float 3s ease-in-out infinite",
|
||||||
@ -458,33 +454,33 @@ const LessonDetail = () => {
|
|||||||
>
|
>
|
||||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
{/* Академическая шапочка */}
|
{/* Академическая шапочка */}
|
||||||
<path
|
<path
|
||||||
d="M12 2L2 6.5L12 11L22 6.5L12 2Z"
|
d="M12 2L2 6.5L12 11L22 6.5L12 2Z"
|
||||||
fill={colorMode === "light" ? "#3182CE" : "#63B3ED"}
|
fill={colorMode === "light" ? "#3182CE" : "#63B3ED"}
|
||||||
/>
|
/>
|
||||||
<path
|
<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"
|
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"}
|
fill={colorMode === "light" ? "#2C5282" : "#4299E1"}
|
||||||
/>
|
/>
|
||||||
<path
|
<path
|
||||||
d="M21 7V14M21 14L19 16M21 14L23 16"
|
d="M21 7V14M21 14L19 16M21 14L23 16"
|
||||||
stroke={colorMode === "light" ? "#2C5282" : "#4299E1"}
|
stroke={colorMode === "light" ? "#2C5282" : "#4299E1"}
|
||||||
strokeWidth="1.5"
|
strokeWidth="1.5"
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Лицо студента */}
|
{/* Лицо студента */}
|
||||||
<circle
|
<circle
|
||||||
cx="12"
|
cx="12"
|
||||||
cy="15"
|
cy="15"
|
||||||
r="2.5"
|
r="2.5"
|
||||||
fill={colorMode === "light" ? "#3182CE" : "#63B3ED"}
|
fill={colorMode === "light" ? "#3182CE" : "#63B3ED"}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Тело студента */}
|
{/* Тело студента */}
|
||||||
<path
|
<path
|
||||||
d="M8 18.5C8 17.1193 9.11929 16 10.5 16H13.5C14.8807 16 16 17.1193 16 18.5V21H8V18.5Z"
|
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"}
|
fill={colorMode === "light" ? "#3182CE" : "#63B3ED"}
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
@ -492,8 +488,8 @@ const LessonDetail = () => {
|
|||||||
<Box fontSize="sm" fontWeight="medium">
|
<Box fontSize="sm" fontWeight="medium">
|
||||||
{student.name || student.preferred_username}
|
{student.name || student.preferred_username}
|
||||||
</Box>
|
</Box>
|
||||||
<Box
|
<Box
|
||||||
fontSize="xs"
|
fontSize="xs"
|
||||||
opacity={0.8}
|
opacity={0.8}
|
||||||
color={colorMode === "light" ? "gray.600" : "gray.300"}
|
color={colorMode === "light" ? "gray.600" : "gray.300"}
|
||||||
>
|
>
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect } from 'react'
|
import React, { useEffect, useState } from 'react'
|
||||||
import { useForm, Controller } from 'react-hook-form'
|
import { useForm, Controller } from 'react-hook-form'
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
@ -24,9 +24,14 @@ import {
|
|||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
SkeletonText,
|
SkeletonText,
|
||||||
useStyleConfig
|
useStyleConfig,
|
||||||
|
Select,
|
||||||
|
Wrap,
|
||||||
|
WrapItem,
|
||||||
|
IconButton,
|
||||||
|
Center
|
||||||
} from '@chakra-ui/react'
|
} from '@chakra-ui/react'
|
||||||
import { AddIcon, CheckIcon, WarningIcon, RepeatIcon } from '@chakra-ui/icons'
|
import { AddIcon, CheckIcon, WarningIcon, RepeatIcon, ChevronLeftIcon, ChevronRightIcon } from '@chakra-ui/icons'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { FaRobot } from 'react-icons/fa'
|
import { FaRobot } from 'react-icons/fa'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
@ -39,6 +44,7 @@ import { ErrorSpan } from '../style'
|
|||||||
interface NewLessonForm {
|
interface NewLessonForm {
|
||||||
name: string
|
name: string
|
||||||
date: string
|
date: string
|
||||||
|
time: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LessonFormProps {
|
interface LessonFormProps {
|
||||||
@ -54,6 +60,7 @@ interface LessonFormProps {
|
|||||||
onSelectAiSuggestion?: (suggestion: any) => void // Обработчик выбора предложения
|
onSelectAiSuggestion?: (suggestion: any) => void // Обработчик выбора предложения
|
||||||
selectedAiSuggestion?: any // Выбранное предложение
|
selectedAiSuggestion?: any // Выбранное предложение
|
||||||
onRetryAiGeneration?: () => void // Функция для повторного запуска генерации
|
onRetryAiGeneration?: () => void // Функция для повторного запуска генерации
|
||||||
|
existingLessons?: Array<{ date: string; name: string }> // Добавляем новый проп
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LessonForm = ({
|
export const LessonForm = ({
|
||||||
@ -68,7 +75,8 @@ export const LessonForm = ({
|
|||||||
isLoadingAiSuggestions = false,
|
isLoadingAiSuggestions = false,
|
||||||
onSelectAiSuggestion = () => {},
|
onSelectAiSuggestion = () => {},
|
||||||
selectedAiSuggestion,
|
selectedAiSuggestion,
|
||||||
onRetryAiGeneration = () => {}
|
onRetryAiGeneration = () => {},
|
||||||
|
existingLessons
|
||||||
}: LessonFormProps) => {
|
}: LessonFormProps) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const isAiSuggested = lesson && !lesson._id && !lesson.id
|
const isAiSuggested = lesson && !lesson._id && !lesson.id
|
||||||
@ -131,6 +139,257 @@ export const LessonForm = ({
|
|||||||
onSelectAiSuggestion(suggestion)
|
onSelectAiSuggestion(suggestion)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Добавляем новые вспомогательные функции
|
||||||
|
const generateTimeSlots = () => {
|
||||||
|
const slots = [];
|
||||||
|
for (let hour = 8; hour <= 21; hour++) {
|
||||||
|
slots.push(`${hour.toString().padStart(2, '0')}:00`);
|
||||||
|
slots.push(`${hour.toString().padStart(2, '0')}:30`);
|
||||||
|
}
|
||||||
|
return slots;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNextTimeSlots = (date: string, count: number = 3) => {
|
||||||
|
const currentDate = new Date();
|
||||||
|
const selectedDate = new Date(date);
|
||||||
|
const isToday = selectedDate.toDateString() === currentDate.toDateString();
|
||||||
|
|
||||||
|
if (!isToday) return [];
|
||||||
|
|
||||||
|
const currentMinutes = currentDate.getHours() * 60 + currentDate.getMinutes();
|
||||||
|
const slots = generateTimeSlots();
|
||||||
|
|
||||||
|
return slots
|
||||||
|
.map(slot => {
|
||||||
|
const [hours, minutes] = slot.split(':').map(Number);
|
||||||
|
const slotMinutes = hours * 60 + minutes;
|
||||||
|
return { slot, minutes: slotMinutes };
|
||||||
|
})
|
||||||
|
.filter(({ minutes }) => minutes > currentMinutes)
|
||||||
|
.slice(0, count)
|
||||||
|
.map(({ slot }) => slot);
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeGroups = {
|
||||||
|
[`${t('journal.pl.days.morning')} (8-12)`]: generateTimeSlots().filter(slot => {
|
||||||
|
const hour = parseInt(slot.split(':')[0]);
|
||||||
|
return hour >= 8 && hour < 12;
|
||||||
|
}),
|
||||||
|
[`${t('journal.pl.days.day')} (12-17)`]: generateTimeSlots().filter(slot => {
|
||||||
|
const hour = parseInt(slot.split(':')[0]);
|
||||||
|
return hour >= 12 && hour < 17;
|
||||||
|
}),
|
||||||
|
[`${t('journal.pl.days.evening')} (17-21)`]: generateTimeSlots().filter(slot => {
|
||||||
|
const hour = parseInt(slot.split(':')[0]);
|
||||||
|
return hour >= 17 && hour <= 21;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
// Добавляем функцию для получения дня недели
|
||||||
|
const getDayOfWeek = (date: Date) => {
|
||||||
|
const days = [
|
||||||
|
t('journal.pl.days.sunday'),
|
||||||
|
t('journal.pl.days.monday'),
|
||||||
|
t('journal.pl.days.tuesday'),
|
||||||
|
t('journal.pl.days.wednesday'),
|
||||||
|
t('journal.pl.days.thursday'),
|
||||||
|
t('journal.pl.days.friday'),
|
||||||
|
t('journal.pl.days.saturday')
|
||||||
|
];
|
||||||
|
return days[date.getDay()];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Добавляем вспомогательные функции для календаря
|
||||||
|
const getDaysInMonth = (year: number, month: number) => {
|
||||||
|
return new Date(year, month + 1, 0).getDate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFirstDayOfMonth = (year: number, month: number) => {
|
||||||
|
return new Date(year, month, 1).getDay();
|
||||||
|
};
|
||||||
|
|
||||||
|
const isWeekend = (dayOfWeek: number) => {
|
||||||
|
return dayOfWeek === 0 || dayOfWeek === 6; // Воскресенье или суббота
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSameDay = (date1: Date, date2: Date) => {
|
||||||
|
return date1.getFullYear() === date2.getFullYear() &&
|
||||||
|
date1.getMonth() === date2.getMonth() &&
|
||||||
|
date1.getDate() === date2.getDate();
|
||||||
|
};
|
||||||
|
// Компонент календаря
|
||||||
|
interface CalendarProps {
|
||||||
|
selectedDate: Date;
|
||||||
|
onSelectDate: (date: Date) => void;
|
||||||
|
existingLessons?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const Calendar: React.FC<CalendarProps> = ({ selectedDate, onSelectDate, existingLessons = [] }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [viewDate, setViewDate] = useState(new Date());
|
||||||
|
|
||||||
|
// Используем короткие названия дней недели из локализации
|
||||||
|
const weekDays = [
|
||||||
|
t('journal.pl.days.shortMonday'),
|
||||||
|
t('journal.pl.days.shortTuesday'),
|
||||||
|
t('journal.pl.days.shortWednesday'),
|
||||||
|
t('journal.pl.days.shortThursday'),
|
||||||
|
t('journal.pl.days.shortFriday'),
|
||||||
|
t('journal.pl.days.shortSaturday'),
|
||||||
|
t('journal.pl.days.shortSunday'),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Используем локализованные названия месяцев
|
||||||
|
const monthNames = [
|
||||||
|
t('journal.pl.months.january'),
|
||||||
|
t('journal.pl.months.february'),
|
||||||
|
t('journal.pl.months.march'),
|
||||||
|
t('journal.pl.months.april'),
|
||||||
|
t('journal.pl.months.may'),
|
||||||
|
t('journal.pl.months.june'),
|
||||||
|
t('journal.pl.months.july'),
|
||||||
|
t('journal.pl.months.august'),
|
||||||
|
t('journal.pl.months.september'),
|
||||||
|
t('journal.pl.months.october'),
|
||||||
|
t('journal.pl.months.november'),
|
||||||
|
t('journal.pl.months.december'),
|
||||||
|
];
|
||||||
|
|
||||||
|
const daysInMonth = getDaysInMonth(viewDate.getFullYear(), viewDate.getMonth());
|
||||||
|
let firstDay = getFirstDayOfMonth(viewDate.getFullYear(), viewDate.getMonth());
|
||||||
|
firstDay = firstDay === 0 ? 6 : firstDay - 1; // Корректируем для начала недели с понедельника
|
||||||
|
|
||||||
|
const days = Array.from({ length: 42 }, (_, i) => {
|
||||||
|
const dayNumber = i - firstDay + 1;
|
||||||
|
if (dayNumber > 0 && dayNumber <= daysInMonth) {
|
||||||
|
const date = new Date(viewDate.getFullYear(), viewDate.getMonth(), dayNumber);
|
||||||
|
return {
|
||||||
|
date,
|
||||||
|
dayOfMonth: dayNumber,
|
||||||
|
isCurrentMonth: true,
|
||||||
|
isWeekend: isWeekend(date.getDay()),
|
||||||
|
isToday: isSameDay(date, new Date()),
|
||||||
|
isSelected: isSameDay(date, selectedDate)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Добавим функцию проверки наличия лекции в определенный день
|
||||||
|
const hasLessonOnDate = (date: Date) => {
|
||||||
|
return existingLessons.some(lessonDate =>
|
||||||
|
isSameDay(new Date(lessonDate), date)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Text fontSize="sm" mb={2}>{t('journal.pl.lesson.form.selectDate')}</Text>
|
||||||
|
<HStack justify="space-between" mb={2}>
|
||||||
|
<IconButton
|
||||||
|
aria-label="Previous month"
|
||||||
|
icon={<ChevronLeftIcon />}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const newDate = new Date(viewDate);
|
||||||
|
newDate.setMonth(newDate.getMonth() - 1);
|
||||||
|
setViewDate(newDate);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<HStack>
|
||||||
|
<Select
|
||||||
|
size="sm"
|
||||||
|
value={viewDate.getMonth()}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newDate = new Date(viewDate);
|
||||||
|
newDate.setMonth(parseInt(e.target.value));
|
||||||
|
setViewDate(newDate);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{monthNames.map((month, i) => (
|
||||||
|
<option key={i} value={i}>{month}</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<Select
|
||||||
|
size="sm"
|
||||||
|
value={viewDate.getFullYear()}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newDate = new Date(viewDate);
|
||||||
|
newDate.setFullYear(parseInt(e.target.value));
|
||||||
|
setViewDate(newDate);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Array.from({ length: 5 }, (_, i) => {
|
||||||
|
const year = new Date().getFullYear() + i;
|
||||||
|
return <option key={year} value={year}>{year}</option>;
|
||||||
|
})}
|
||||||
|
</Select>
|
||||||
|
</HStack>
|
||||||
|
<IconButton
|
||||||
|
aria-label="Next month"
|
||||||
|
icon={<ChevronRightIcon />}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const newDate = new Date(viewDate);
|
||||||
|
newDate.setMonth(newDate.getMonth() + 1);
|
||||||
|
setViewDate(newDate);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
<SimpleGrid columns={7} spacing={1}>
|
||||||
|
{weekDays.map(day => (
|
||||||
|
<Center key={day} py={1}>
|
||||||
|
<Text fontSize="xs" color="gray.500">
|
||||||
|
{day}
|
||||||
|
</Text>
|
||||||
|
</Center>
|
||||||
|
))}
|
||||||
|
{days.map((day, i) => {
|
||||||
|
const hasLesson = day?.isCurrentMonth && hasLessonOnDate(day.date);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={i}
|
||||||
|
size="sm"
|
||||||
|
variant={day?.isSelected ? "solid" : "ghost"}
|
||||||
|
colorScheme={day?.isSelected ? "blue" : day?.isWeekend ? "red" : "gray"}
|
||||||
|
opacity={day?.isCurrentMonth ? 1 : 0}
|
||||||
|
onClick={() => day?.date && onSelectDate(day.date)}
|
||||||
|
h="32px"
|
||||||
|
disabled={!day?.isCurrentMonth}
|
||||||
|
position="relative"
|
||||||
|
_after={hasLesson ? {
|
||||||
|
content: '""',
|
||||||
|
position: "absolute",
|
||||||
|
bottom: "2px",
|
||||||
|
left: "50%",
|
||||||
|
transform: "translateX(-50%)",
|
||||||
|
width: "4px",
|
||||||
|
height: "4px",
|
||||||
|
borderRadius: "full",
|
||||||
|
bg: day?.isSelected ? "white" : "blue.500",
|
||||||
|
_dark: {
|
||||||
|
bg: day?.isSelected ? "white" : "blue.300"
|
||||||
|
}
|
||||||
|
} : undefined}
|
||||||
|
title={hasLesson ? t('journal.pl.lesson.existingLessonHint') : undefined}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
fontSize="xs"
|
||||||
|
fontWeight={day?.isToday ? "bold" : "normal"}
|
||||||
|
textDecoration={day?.isToday ? "underline" : "none"}
|
||||||
|
>
|
||||||
|
{day?.dayOfMonth}
|
||||||
|
</Text>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SimpleGrid>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card align="left" bg={isAiSuggested ? aiHighlightColor : undefined}>
|
<Card align="left" bg={isAiSuggested ? aiHighlightColor : undefined}>
|
||||||
<CardHeader display="flex">
|
<CardHeader display="flex">
|
||||||
@ -160,23 +419,68 @@ export const LessonForm = ({
|
|||||||
control={control}
|
control={control}
|
||||||
name="date"
|
name="date"
|
||||||
rules={{ required: t('journal.pl.common.required') }}
|
rules={{ required: t('journal.pl.common.required') }}
|
||||||
render={({ field }) => (
|
render={({ field }) => {
|
||||||
<FormControl>
|
const [currentDate = '', currentTime = '00:00:00'] = field.value.split('T');
|
||||||
<FormLabel>{t('journal.pl.lesson.form.date')}</FormLabel>
|
const currentTimeShort = currentTime.split(':').slice(0, 2).join(':');
|
||||||
<Input
|
const selectedDate = new Date(currentDate);
|
||||||
{...field}
|
|
||||||
required={false}
|
// Получаем существующие лекции из пропсов компонента
|
||||||
placeholder={t('journal.pl.lesson.form.datePlaceholder')}
|
const existingLessons2 = existingLessons?.map(lesson => lesson.date) || [];
|
||||||
size="md"
|
|
||||||
type="datetime-local"
|
return (
|
||||||
/>
|
<FormControl>
|
||||||
{errors.date ? (
|
<FormLabel>{t('journal.pl.lesson.form.date')}</FormLabel>
|
||||||
<FormErrorMessage>{errors.date?.message}</FormErrorMessage>
|
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
|
||||||
) : (
|
{/* Календарь */}
|
||||||
<FormHelperText>{t('journal.pl.lesson.form.dateTime')}</FormHelperText>
|
<Box>
|
||||||
)}
|
<Calendar
|
||||||
</FormControl>
|
selectedDate={selectedDate}
|
||||||
)}
|
existingLessons={existingLessons2}
|
||||||
|
onSelectDate={(date) => {
|
||||||
|
const formattedDate = dateToCalendarFormat(date.toISOString()).split('T')[0];
|
||||||
|
field.onChange(`${formattedDate}T${currentTimeShort}:00`);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Временные слоты */}
|
||||||
|
<Box>
|
||||||
|
<Text fontSize="sm" mb={2}>{t('journal.pl.lesson.form.selectTime')}:</Text>
|
||||||
|
<SimpleGrid columns={1} spacing={4}>
|
||||||
|
{Object.entries(timeGroups).map(([groupName, slots]) => (
|
||||||
|
<Box key={groupName}>
|
||||||
|
<Text fontSize="xs" color="gray.500" mb={1}>
|
||||||
|
{groupName}
|
||||||
|
</Text>
|
||||||
|
<Wrap spacing={1}>
|
||||||
|
{slots.map(slot => {
|
||||||
|
const isSelected = currentTimeShort === slot;
|
||||||
|
return (
|
||||||
|
<WrapItem key={slot}>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant={isSelected ? "solid" : "outline"}
|
||||||
|
colorScheme="blue"
|
||||||
|
onClick={() => {
|
||||||
|
field.onChange(`${currentDate}T${slot}:00`);
|
||||||
|
}}
|
||||||
|
h="24px"
|
||||||
|
minW="54px"
|
||||||
|
>
|
||||||
|
{slot}
|
||||||
|
</Button>
|
||||||
|
</WrapItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Wrap>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
</Box>
|
||||||
|
</SimpleGrid>
|
||||||
|
</FormControl>
|
||||||
|
);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Controller
|
<Controller
|
||||||
|
@ -359,6 +359,10 @@ const LessonList = () => {
|
|||||||
onSelectAiSuggestion={handleSelectAiSuggestion}
|
onSelectAiSuggestion={handleSelectAiSuggestion}
|
||||||
selectedAiSuggestion={suggestedLessonToCreate}
|
selectedAiSuggestion={suggestedLessonToCreate}
|
||||||
onRetryAiGeneration={handleRetryAiGeneration}
|
onRetryAiGeneration={handleRetryAiGeneration}
|
||||||
|
existingLessons={data?.body?.map(lesson => ({
|
||||||
|
date: lesson.date,
|
||||||
|
name: lesson.name
|
||||||
|
}))}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
|
@ -15,6 +15,34 @@ const reveal = keyframes`
|
|||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
export const AddMissedButton = styled.button`
|
||||||
|
position: absolute;
|
||||||
|
bottom: 8px;
|
||||||
|
right: 8px;
|
||||||
|
border: none;
|
||||||
|
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 {
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chakra-ui-dark & {
|
||||||
|
background-color: var(--chakra-colors-blue-400);
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
export const StudentList = styled.ul`
|
export const StudentList = styled.ul`
|
||||||
padding: 0;
|
padding: 0;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
|
@ -63,28 +63,28 @@ const UserPage = () => {
|
|||||||
// Эффект для поэтапного появления карточек студентов
|
// Эффект для поэтапного появления карточек студентов
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (ls.data?.body?.students?.length) {
|
if (ls.data?.body?.students?.length) {
|
||||||
// Сначала очищаем список
|
// Обновляем существующих студентов с сохранением их анимации
|
||||||
setAnimatedStudents([])
|
setAnimatedStudents(prevStudents => {
|
||||||
|
const newStudents = ls.data.body.students.map(student => {
|
||||||
// Затем постепенно добавляем студентов для красивой анимации
|
// Находим существующего студента
|
||||||
const students = [...ls.data.body.students]
|
const existingStudent = prevStudents.find(p => p.sub === student.sub);
|
||||||
const addStudentWithDelay = (index) => {
|
// Сохраняем флаг isNew если студент уже существует
|
||||||
if (index < students.length) {
|
return {
|
||||||
setAnimatedStudents(prev => [...prev, {...students[index], isNew: true}])
|
...student,
|
||||||
|
isNew: existingStudent ? existingStudent.isNew : true
|
||||||
// Для следующего студента
|
};
|
||||||
setTimeout(() => {
|
});
|
||||||
addStudentWithDelay(index + 1)
|
|
||||||
}, 100) // Уменьшенная задержка для более плавной анимации
|
// Если количество студентов не изменилось, сохраняем текущий массив
|
||||||
|
if (prevStudents.length === newStudents.length &&
|
||||||
|
prevStudents.every(student => newStudents.find(n => n.sub === student.sub))) {
|
||||||
|
return prevStudents;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
return newStudents;
|
||||||
// Запускаем процесс добавления с небольшой задержкой для лучшего UX
|
});
|
||||||
setTimeout(() => {
|
|
||||||
addStudentWithDelay(0)
|
|
||||||
}, 300)
|
|
||||||
}
|
}
|
||||||
}, [ls.data?.body?.students])
|
}, [ls.data?.body?.students, ls.data?.body?.studentReactions])
|
||||||
|
|
||||||
// Эффект для сброса флага "новизны" студентов
|
// Эффект для сброса флага "новизны" студентов
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -277,7 +277,7 @@ const UserPage = () => {
|
|||||||
student={student}
|
student={student}
|
||||||
present={true}
|
present={true}
|
||||||
recentlyPresent={student.isNew}
|
recentlyPresent={student.isNew}
|
||||||
reactions={ls.data?.body?.reactions?.filter(r => r.sub === student.sub) || []}
|
reaction={ls.data?.body?.studentReactions?.find(r => r.sub === student.sub)}
|
||||||
/>
|
/>
|
||||||
</motion.li>
|
</motion.li>
|
||||||
))}
|
))}
|
||||||
|
@ -35,7 +35,7 @@ function readAndModifyJson(filePath) {
|
|||||||
jsonContent.body.forEach((lesson) => {
|
jsonContent.body.forEach((lesson) => {
|
||||||
// Случайная дата в пределах последних 3 месяцев
|
// Случайная дата в пределах последних 3 месяцев
|
||||||
const randomDate = new Date();
|
const randomDate = new Date();
|
||||||
randomDate.setMonth(randomDate.getMonth() - Math.random() * 3);
|
randomDate.setDate(randomDate.getDate() - Math.random() * 30);
|
||||||
lesson.date = randomDate.toISOString();
|
lesson.date = randomDate.toISOString();
|
||||||
lesson.created = new Date(randomDate.getTime() - 86400000).toISOString(); // Создан за день до даты
|
lesson.created = new Date(randomDate.getTime() - 86400000).toISOString(); // Создан за день до даты
|
||||||
});
|
});
|
||||||
|
@ -590,8 +590,8 @@
|
|||||||
"sub": "developer",
|
"sub": "developer",
|
||||||
"email": "email@email.ml"
|
"email": "email@email.ml"
|
||||||
},
|
},
|
||||||
"startDt": "2024-08-25T17:40:17.814Z",
|
"startDt": "2024-08-25T17:30:00.000Z",
|
||||||
"created": "2024-08-25T17:40:17.814Z",
|
"created": "2024-08-25T17:40:17.000Z",
|
||||||
"examWithJury2": {
|
"examWithJury2": {
|
||||||
"_id": "66cf3d3f4637d420d6271451",
|
"_id": "66cf3d3f4637d420d6271451",
|
||||||
"name": "Хакатон",
|
"name": "Хакатон",
|
||||||
|
@ -19,7 +19,7 @@
|
|||||||
"email": "primakovpro@gmail.com"
|
"email": "primakovpro@gmail.com"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"date": "2024-04-16T13:38:00.000Z",
|
"date": "2024-04-16T13:30:00.000Z",
|
||||||
"created": "2024-04-16T13:38:23.381Z",
|
"created": "2024-04-16T13:38:23.381Z",
|
||||||
"id": "661e7f4f69f40b0ebebcd5e4"
|
"id": "661e7f4f69f40b0ebebcd5e4"
|
||||||
},
|
},
|
||||||
@ -37,7 +37,7 @@
|
|||||||
"email": "primakovpro@gmail.com"
|
"email": "primakovpro@gmail.com"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"date": "2024-08-04T07:00:00.000Z",
|
"date": "2024-08-04T08:00:00.000Z",
|
||||||
"created": "2024-08-04T06:23:28.491Z",
|
"created": "2024-08-04T06:23:28.491Z",
|
||||||
"id": "66af1e60a0eef5a89f99aa94"
|
"id": "66af1e60a0eef5a89f99aa94"
|
||||||
},
|
},
|
||||||
|
@ -27,7 +27,7 @@
|
|||||||
"picture": "https://lh3.googleusercontent.com/a/ACg8ocJUtJBAVBm642AxoGpMDDMV8CPu3MEoLjU3hmO7oisG=s96-c"
|
"picture": "https://lh3.googleusercontent.com/a/ACg8ocJUtJBAVBm642AxoGpMDDMV8CPu3MEoLjU3hmO7oisG=s96-c"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"reactions": [
|
"studentReactions": [
|
||||||
{
|
{
|
||||||
"_id": "r1d73f22-c9ba-422a-b572-c59e515a2901",
|
"_id": "r1d73f22-c9ba-422a-b572-c59e515a2901",
|
||||||
"sub": "fcde3f22-d9ba-412a-a572-c59e515a290f",
|
"sub": "fcde3f22-d9ba-412a-a572-c59e515a290f",
|
||||||
|
Loading…
x
Reference in New Issue
Block a user