Compare commits

...

15 Commits
flip ... master

Author SHA1 Message Date
183e3826be 3.16.5
All checks were successful
platform/bro-js/journal.pl/pipeline/head This commit looks good
2025-04-03 23:07:48 +03:00
1ec4bc081e mark student button 2025-04-03 23:07:41 +03:00
Primakov Alexandr Alexandrovich
870ac5348b 3.16.4
All checks were successful
platform/bro-js/journal.pl/pipeline/head This commit looks good
2025-03-27 14:23:32 +03:00
Primakov Alexandr Alexandrovich
d648a181c3 Упрощено управление реакциями студентов в карточке пользователя. Изменено состояние реакций на использование одного объекта вместо массива, улучшена анимация отображения реакций. 2025-03-27 14:14:31 +03:00
Primakov Alexandr Alexandrovich
56a04dbe14 Оптимизация обновления реакций студентов и анимации карточек на странице пользователя. Упрощено добавление новых реакций и улучшено управление состоянием анимации студентов. 2025-03-27 14:01:12 +03:00
Primakov Alexandr Alexandrovich
5a92ff2bee 3.16.3 2025-03-27 13:54:13 +03:00
Primakov Alexandr Alexandrovich
543796740b user page reactions fix 2025-03-27 13:54:09 +03:00
Primakov Alexandr Alexandrovich
452d451224 3.16.2 2025-03-27 13:51:29 +03:00
Primakov Alexandr Alexandrovich
23c943f05d force show new reaction 2025-03-27 13:51:25 +03:00
Primakov Alexandr Alexandrovich
c87413eb2c 3.16.1 2025-03-27 13:45:48 +03:00
Primakov Alexandr Alexandrovich
245d56410d fix read students reactions 2025-03-27 13:45:42 +03:00
Primakov Alexandr Alexandrovich
424013c570 3.16.0 2025-03-27 00:00:26 +03:00
Primakov Alexandr Alexandrovich
8a66b96599 Обновлены локализации для дней недели и месяцев, добавлены новые строки для выбора даты и существующих уроков. В компоненте формы уроков реализован календарь для выбора даты с учетом существующих лекций. 2025-03-26 23:41:05 +03:00
Primakov Alexandr Alexandrovich
32aad802b9 Добавлены новые временные слоты и улучшена форма выбора даты и времени для уроков. Реализованы функции для генерации временных слотов и получения следующего доступного времени. Обновлены локализации для новых строк. 2025-03-26 23:20:25 +03:00
Primakov Alexandr Alexandrovich
03a6172d91 3.15.1
All checks were successful
platform/bro-js/journal.pl/pipeline/head This commit looks good
2025-03-26 19:15:13 +03:00
16 changed files with 610 additions and 271 deletions

View File

@ -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"
} }

View File

@ -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
View File

@ -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",

View File

@ -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": {

View File

@ -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;

View File

@ -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);
}
`

View File

@ -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>
) )

View File

@ -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"}
> >

View File

@ -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

View File

@ -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

View File

@ -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;

View File

@ -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>
))} ))}

View File

@ -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(); // Создан за день до даты
}); });

View File

@ -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": "Хакатон",

View File

@ -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"
}, },

View File

@ -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",