Compare commits
21 Commits
flip
...
688337f778
| Author | SHA1 | Date | |
|---|---|---|---|
| 688337f778 | |||
| f6edca8c75 | |||
| 80338c9bfc | |||
| 23e8b81aa7 | |||
| 99dff44fd1 | |||
| ad6b36c4ca | |||
| 8f67b7511d | |||
| bda0d1673e | |||
|
|
870ac5348b | ||
|
|
d648a181c3 | ||
|
|
56a04dbe14 | ||
|
|
5a92ff2bee | ||
|
|
543796740b | ||
|
|
452d451224 | ||
|
|
23c943f05d | ||
|
|
c87413eb2c | ||
|
|
245d56410d | ||
|
|
424013c570 | ||
|
|
8a66b96599 | ||
|
|
32aad802b9 | ||
|
|
03a6172d91 |
@@ -93,6 +93,14 @@
|
||||
"journal.pl.lesson.noStudents": "No Students Yet",
|
||||
"journal.pl.lesson.waitForStudents": "Students who attend the lesson will appear here",
|
||||
"journal.pl.lesson.notMarked": "Not yet marked",
|
||||
"journal.pl.lesson.attendance": "Attendance",
|
||||
"journal.pl.lesson.recentlyJoined": "Recently joined",
|
||||
"journal.pl.lesson.veryPoorAttendance": "Very poor attendance",
|
||||
"journal.pl.lesson.poorAttendance": "Poor attendance",
|
||||
"journal.pl.lesson.lowAttendance": "Low attendance",
|
||||
"journal.pl.lesson.mediumAttendance": "Medium attendance",
|
||||
"journal.pl.lesson.goodAttendance": "Good attendance",
|
||||
"journal.pl.lesson.excellentAttendance": "Excellent attendance",
|
||||
|
||||
"journal.pl.reactions.thumbs_up": "Thumbs up",
|
||||
"journal.pl.reactions.heart": "Heart",
|
||||
@@ -210,5 +218,33 @@
|
||||
"journal.pl.overview.new": "new",
|
||||
"journal.pl.overview.pastLessonsStats": "Statistics of past lessons",
|
||||
"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"
|
||||
}
|
||||
@@ -90,6 +90,14 @@
|
||||
"journal.pl.lesson.noStudents": "Пока нет студентов",
|
||||
"journal.pl.lesson.waitForStudents": "Студенты, посетившие занятие, появятся здесь",
|
||||
"journal.pl.lesson.notMarked": "Не отмечен",
|
||||
"journal.pl.lesson.attendance": "Посещаемость",
|
||||
"journal.pl.lesson.recentlyJoined": "Недавно присоединился",
|
||||
"journal.pl.lesson.veryPoorAttendance": "В первый раз?",
|
||||
"journal.pl.lesson.poorAttendance": "Не узнаю",
|
||||
"journal.pl.lesson.lowAttendance": "Редкий гость",
|
||||
"journal.pl.lesson.mediumAttendance": "Бывает заходит",
|
||||
"journal.pl.lesson.goodAttendance": "Часто заходит",
|
||||
"journal.pl.lesson.excellentAttendance": "Часто заходит и не уходит",
|
||||
|
||||
"journal.pl.reactions.thumbs_up": "Палец вверх",
|
||||
"journal.pl.reactions.heart": "Сердце",
|
||||
@@ -207,5 +215,33 @@
|
||||
"journal.pl.overview.new": "новых",
|
||||
"journal.pl.overview.pastLessonsStats": "Статистика проведённых занятий",
|
||||
"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": "Декабрь"
|
||||
}
|
||||
881
package-lock.json
generated
881
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "journal.pl",
|
||||
"version": "3.15.0",
|
||||
"version": "3.17.2",
|
||||
"description": "bro-js platform journal ui repo",
|
||||
"main": "./src/index.tsx",
|
||||
"scripts": {
|
||||
|
||||
@@ -59,7 +59,7 @@ export interface Lesson {
|
||||
id: string;
|
||||
_id: string;
|
||||
name: string;
|
||||
reactions: Reaction[];
|
||||
studentReactions: Reaction[];
|
||||
students: User[];
|
||||
teachers: Teacher[];
|
||||
date: string;
|
||||
|
||||
63
src/components/lesson/AttendanceStats.tsx
Normal file
63
src/components/lesson/AttendanceStats.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import React from 'react'
|
||||
import { Box } from '@chakra-ui/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { formatDate } from '../../utils/dayjs-config'
|
||||
import { getAttendanceColor } from '../../utils/attendance-colors'
|
||||
|
||||
interface AttendanceStatsProps {
|
||||
lessonDate: string
|
||||
studentsPresent: number
|
||||
totalStudents: number
|
||||
isLoading: boolean
|
||||
isPulsing: boolean
|
||||
}
|
||||
|
||||
export const AttendanceStats: React.FC<AttendanceStatsProps> = ({
|
||||
lessonDate,
|
||||
studentsPresent,
|
||||
totalStudents,
|
||||
isLoading,
|
||||
isPulsing
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const attendanceColors = getAttendanceColor(studentsPresent, totalStudents)
|
||||
|
||||
return (
|
||||
<Box pb={3}>
|
||||
{formatDate(lessonDate, t('journal.pl.lesson.dateFormat'))}{' '}
|
||||
{t('journal.pl.common.marked')} -
|
||||
{!isLoading && (
|
||||
<Box
|
||||
as="span"
|
||||
px={2}
|
||||
py={1}
|
||||
ml={2}
|
||||
borderRadius="md"
|
||||
fontWeight="bold"
|
||||
bg={attendanceColors.bg}
|
||||
color={attendanceColors.color}
|
||||
_dark={{
|
||||
bg: attendanceColors.dark.bg,
|
||||
color: attendanceColors.dark.color
|
||||
}}
|
||||
position="relative"
|
||||
animation={isPulsing ? "pulse 1.5s ease-in-out" : "none"}
|
||||
sx={{
|
||||
'@keyframes pulse': {
|
||||
'0%': { transform: 'scale(1)' },
|
||||
'50%': { transform: 'scale(1.15)', boxShadow: '0 0 10px rgba(66, 153, 225, 0.7)' },
|
||||
'100%': { transform: 'scale(1)' }
|
||||
}
|
||||
}}
|
||||
>
|
||||
{studentsPresent} / {totalStudents}
|
||||
</Box>
|
||||
)}
|
||||
{isLoading && (
|
||||
<span> {studentsPresent}</span>
|
||||
)}{' '}
|
||||
{t('journal.pl.common.people')}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
27
src/components/lesson/QRCodeDisplay.tsx
Normal file
27
src/components/lesson/QRCodeDisplay.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React, { useRef } from 'react'
|
||||
|
||||
import { QRCanvas } from '../../pages/style'
|
||||
import { useQRCode } from '../../hooks/useQRCode'
|
||||
|
||||
interface QRCodeDisplayProps {
|
||||
url: string
|
||||
isFetching: boolean
|
||||
isSuccess: boolean
|
||||
}
|
||||
|
||||
export const QRCodeDisplay: React.FC<QRCodeDisplayProps> = ({
|
||||
url,
|
||||
isFetching,
|
||||
isSuccess
|
||||
}) => {
|
||||
const canvRef = useRef(null)
|
||||
|
||||
// Use the QR code hook
|
||||
useQRCode(canvRef, url, isFetching, isSuccess)
|
||||
|
||||
return (
|
||||
<a href={url}>
|
||||
<QRCanvas ref={canvRef} />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
174
src/components/lesson/StudentCard.tsx
Normal file
174
src/components/lesson/StudentCard.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import React from 'react'
|
||||
import { Box } from '@chakra-ui/react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { User, Reaction } from '../../__data__/model'
|
||||
import { UserCard } from '../user-card'
|
||||
import { StudentCardBack } from './StudentCardBack'
|
||||
import { useColorMode } from '@chakra-ui/react'
|
||||
|
||||
// Компонент маленькой батарейки для отображения в углу карточки
|
||||
const BatteryIndicator: React.FC<{
|
||||
student: User & { present?: boolean; recentlyPresent?: boolean }
|
||||
}> = ({ student }) => {
|
||||
const { colorMode } = useColorMode();
|
||||
|
||||
// Та же логика из StudentCardBack для определения уровня батареи
|
||||
const getAttendanceLevel = () => {
|
||||
if (student.present) {
|
||||
return student.recentlyPresent ? 4 : 5;
|
||||
}
|
||||
|
||||
const id = student.sub || '';
|
||||
const idSum = id.split('').reduce((sum, char) => sum + char.charCodeAt(0), 0);
|
||||
return Math.min(3, Math.floor((idSum % 100) / 25));
|
||||
}
|
||||
|
||||
const batteryLevel = getAttendanceLevel();
|
||||
|
||||
// Цвета для разных уровней заряда батареи
|
||||
const colors = [
|
||||
// Empty (0)
|
||||
{ primary: colorMode === "light" ? "#E53E3E" : "#F56565" },
|
||||
// Very Low (1)
|
||||
{ primary: colorMode === "light" ? "#DD6B20" : "#ED8936" },
|
||||
// Low (2)
|
||||
{ primary: colorMode === "light" ? "#D69E2E" : "#ECC94B" },
|
||||
// Medium (3)
|
||||
{ primary: colorMode === "light" ? "#38B2AC" : "#4FD1C5" },
|
||||
// Good (4)
|
||||
{ primary: colorMode === "light" ? "#3182CE" : "#4299E1" },
|
||||
// Excellent (5)
|
||||
{ primary: colorMode === "light" ? "#38A169" : "#48BB78" }
|
||||
];
|
||||
|
||||
const color = colors[batteryLevel].primary;
|
||||
|
||||
// Функция для определения заполненности сегментов
|
||||
const getSegmentFill = (segmentIndex: number) => {
|
||||
return segmentIndex <= batteryLevel ? color : 'transparent';
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
position="absolute"
|
||||
top="8px"
|
||||
right="8px"
|
||||
width="20px"
|
||||
height="10px"
|
||||
zIndex="1"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
{/* Battery outline */}
|
||||
<rect
|
||||
x="2"
|
||||
y="6"
|
||||
width="18"
|
||||
height="12"
|
||||
rx="2"
|
||||
stroke={color}
|
||||
strokeWidth="1.5"
|
||||
fill="transparent"
|
||||
/>
|
||||
{/* Battery cap */}
|
||||
<path
|
||||
d="M20 10H22V14H20V10Z"
|
||||
fill={color}
|
||||
/>
|
||||
|
||||
{/* Battery segments */}
|
||||
<rect x="4" y="8" width="2.3" height="8" rx="1" fill={getSegmentFill(0)} />
|
||||
<rect x="7" y="8" width="2.3" height="8" rx="1" fill={getSegmentFill(1)} />
|
||||
<rect x="10" y="8" width="2.3" height="8" rx="1" fill={getSegmentFill(2)} />
|
||||
<rect x="13" y="8" width="2.3" height="8" rx="1" fill={getSegmentFill(3)} />
|
||||
<rect x="16" y="8" width="2.3" height="8" rx="1" fill={getSegmentFill(4)} />
|
||||
|
||||
{/* Lightning icon if recently joined or fully charged */}
|
||||
{(student.recentlyPresent || batteryLevel === 5) && (
|
||||
<path
|
||||
d="M11.5 7.5L8 12H11L9.5 16.5L13 12H10L11.5 7.5Z"
|
||||
fill={color}
|
||||
stroke={color}
|
||||
strokeWidth="0.3"
|
||||
opacity="0.9"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
interface StudentCardProps {
|
||||
student: User & { present?: boolean; recentlyPresent?: boolean }
|
||||
onAddUser: (user: User) => void
|
||||
reaction?: Reaction
|
||||
}
|
||||
|
||||
export const StudentCard: React.FC<StudentCardProps> = ({
|
||||
student,
|
||||
onAddUser,
|
||||
reaction
|
||||
}) => {
|
||||
return (
|
||||
<motion.li
|
||||
key={student.sub}
|
||||
animate={{
|
||||
rotateY: student.present ? 0 : 180,
|
||||
boxShadow: student.recentlyPresent
|
||||
? ['0 0 0 0 rgba(66, 153, 225, 0)', '0 0 20px 5px rgba(66, 153, 225, 0.7)', '0 0 0 0 rgba(66, 153, 225, 0)']
|
||||
: '0 0 0 0 rgba(0, 0, 0, 0)'
|
||||
}}
|
||||
transition={{
|
||||
rotateY: { type: "spring", stiffness: 300, damping: 20 },
|
||||
boxShadow: {
|
||||
repeat: student.recentlyPresent ? 3 : 0,
|
||||
duration: 1.5
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
transformStyle: "preserve-3d",
|
||||
perspective: "1000px",
|
||||
aspectRatio: "1",
|
||||
width: "100%",
|
||||
display: "block"
|
||||
}}
|
||||
>
|
||||
{/* Container for 3D effect */}
|
||||
<Box
|
||||
position="relative"
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={{
|
||||
transformStyle: "preserve-3d"
|
||||
}}
|
||||
>
|
||||
{/* Front side - visible when present */}
|
||||
<Box
|
||||
position="absolute"
|
||||
top="0"
|
||||
left="0"
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={{
|
||||
backfaceVisibility: "hidden",
|
||||
transform: "rotateY(0deg)",
|
||||
zIndex: student.present ? 1 : 0
|
||||
}}
|
||||
>
|
||||
<UserCard
|
||||
wrapperAS="div"
|
||||
student={student}
|
||||
present={student.present}
|
||||
recentlyPresent={student.recentlyPresent}
|
||||
reaction={reaction}
|
||||
/>
|
||||
|
||||
{/* Battery indicator in corner */}
|
||||
<BatteryIndicator student={student} />
|
||||
</Box>
|
||||
|
||||
{/* Back side */}
|
||||
<StudentCardBack onAddUser={onAddUser} student={student} />
|
||||
</Box>
|
||||
</motion.li>
|
||||
)
|
||||
}
|
||||
280
src/components/lesson/StudentCardBack.tsx
Normal file
280
src/components/lesson/StudentCardBack.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
import React from 'react'
|
||||
import { Box, Flex, useColorMode } from '@chakra-ui/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { User } from '../../__data__/model'
|
||||
import { AddMissedButton } from './sstyle'
|
||||
import { AddIcon } from '@chakra-ui/icons'
|
||||
|
||||
interface StudentCardBackProps {
|
||||
student: User & { present?: boolean; recentlyPresent?: boolean }
|
||||
onAddUser?: (user: User) => void
|
||||
}
|
||||
|
||||
export const StudentCardBack: React.FC<StudentCardBackProps> = ({ student, onAddUser }) => {
|
||||
const { colorMode } = useColorMode()
|
||||
const { t } = useTranslation()
|
||||
|
||||
// Determine attendance level based on student data
|
||||
// We simulate different battery levels based on student presence
|
||||
const getAttendanceLevel = () => {
|
||||
// Using a scale of 0-5 for battery levels (6 segments total)
|
||||
// In a real scenario, you would calculate this based on attendance history
|
||||
|
||||
// If student is present, show higher battery level
|
||||
if (student.present) {
|
||||
return student.recentlyPresent ? 4 : 5; // Full or almost full if present
|
||||
}
|
||||
|
||||
// For absent students, randomly assign a lower battery level (0-3)
|
||||
// In a real implementation, this would come from attendance history
|
||||
const id = student.sub || '';
|
||||
// Use the student ID hash to deterministically assign a battery level
|
||||
// This creates a pseudo-random but consistent level for each student
|
||||
const idSum = id.split('').reduce((sum, char) => sum + char.charCodeAt(0), 0);
|
||||
return Math.min(3, Math.floor((idSum % 100) / 25));
|
||||
}
|
||||
|
||||
const batteryLevel = getAttendanceLevel();
|
||||
|
||||
// Check if student recently joined the class
|
||||
const isRecentlyJoined = !!student.recentlyPresent;
|
||||
|
||||
// Get color scheme based on battery level
|
||||
const getBatteryColors = () => {
|
||||
// Colors for different battery levels
|
||||
const colorSchemes = [
|
||||
// Empty (0)
|
||||
{
|
||||
light: {
|
||||
primary: "#E53E3E", // red.500
|
||||
secondary: "#FED7D7", // red.100
|
||||
accent: "#C53030", // red.700
|
||||
text: "journal.pl.lesson.veryPoorAttendance"
|
||||
},
|
||||
dark: {
|
||||
primary: "#F56565", // red.400
|
||||
secondary: "#9B2C2C", // red.800
|
||||
accent: "#FEB2B2", // red.200
|
||||
text: "journal.pl.lesson.veryPoorAttendance"
|
||||
}
|
||||
},
|
||||
// Very Low (1)
|
||||
{
|
||||
light: {
|
||||
primary: "#DD6B20", // orange.500
|
||||
secondary: "#FEEBC8", // orange.100
|
||||
accent: "#C05621", // orange.700
|
||||
text: "journal.pl.lesson.poorAttendance"
|
||||
},
|
||||
dark: {
|
||||
primary: "#ED8936", // orange.400
|
||||
secondary: "#9C4221", // orange.800
|
||||
accent: "#FBD38D", // orange.200
|
||||
text: "journal.pl.lesson.poorAttendance"
|
||||
}
|
||||
},
|
||||
// Low (2)
|
||||
{
|
||||
light: {
|
||||
primary: "#D69E2E", // yellow.500
|
||||
secondary: "#FEFCBF", // yellow.100
|
||||
accent: "#B7791F", // yellow.700
|
||||
text: "journal.pl.lesson.lowAttendance"
|
||||
},
|
||||
dark: {
|
||||
primary: "#ECC94B", // yellow.400
|
||||
secondary: "#975A16", // yellow.800
|
||||
accent: "#F6E05E", // yellow.200
|
||||
text: "journal.pl.lesson.lowAttendance"
|
||||
}
|
||||
},
|
||||
// Medium (3)
|
||||
{
|
||||
light: {
|
||||
primary: "#38B2AC", // teal.500
|
||||
secondary: "#B2F5EA", // teal.100
|
||||
accent: "#285E61", // teal.700
|
||||
text: "journal.pl.lesson.mediumAttendance"
|
||||
},
|
||||
dark: {
|
||||
primary: "#4FD1C5", // teal.400
|
||||
secondary: "#234E52", // teal.800
|
||||
accent: "#81E6D9", // teal.200
|
||||
text: "journal.pl.lesson.mediumAttendance"
|
||||
}
|
||||
},
|
||||
// Good (4)
|
||||
{
|
||||
light: {
|
||||
primary: "#3182CE", // blue.500
|
||||
secondary: "#BEE3F8", // blue.100
|
||||
accent: "#2C5282", // blue.700
|
||||
text: "journal.pl.lesson.goodAttendance"
|
||||
},
|
||||
dark: {
|
||||
primary: "#4299E1", // blue.400
|
||||
secondary: "#2A4365", // blue.800
|
||||
accent: "#90CDF4", // blue.200
|
||||
text: "journal.pl.lesson.goodAttendance"
|
||||
}
|
||||
},
|
||||
// Excellent (5)
|
||||
{
|
||||
light: {
|
||||
primary: "#38A169", // green.500
|
||||
secondary: "#C6F6D5", // green.100
|
||||
accent: "#276749", // green.700
|
||||
text: "journal.pl.lesson.excellentAttendance"
|
||||
},
|
||||
dark: {
|
||||
primary: "#48BB78", // green.400
|
||||
secondary: "#22543D", // green.800
|
||||
accent: "#9AE6B4", // green.200
|
||||
text: "journal.pl.lesson.excellentAttendance"
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const scheme = colorSchemes[batteryLevel];
|
||||
return scheme[colorMode === 'light' ? 'light' : 'dark'];
|
||||
}
|
||||
|
||||
const colors = getBatteryColors();
|
||||
|
||||
// Function to determine which battery segments to fill based on level
|
||||
const getSegmentFill = (segmentIndex: number) => {
|
||||
const isActive = segmentIndex <= batteryLevel;
|
||||
|
||||
return isActive ? colors.primary : 'transparent';
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex
|
||||
position="absolute"
|
||||
top="0"
|
||||
left="0"
|
||||
width="100%"
|
||||
height="100%"
|
||||
bg={colorMode === "light" ? "white" : "gray.700"}
|
||||
borderRadius="12px"
|
||||
align="center"
|
||||
justify="center"
|
||||
p={4}
|
||||
overflow="hidden"
|
||||
style={{
|
||||
backfaceVisibility: "hidden",
|
||||
transform: "rotateY(180deg)",
|
||||
zIndex: 0,
|
||||
aspectRatio: "1"
|
||||
}}
|
||||
>
|
||||
<AddMissedButton onClick={() => onAddUser(student)} aria-label={t('journal.pl.common.add')}>
|
||||
<AddIcon boxSize={3} />
|
||||
</AddMissedButton>
|
||||
<Box
|
||||
position="absolute"
|
||||
top="0"
|
||||
left="0"
|
||||
right="0"
|
||||
bottom="0"
|
||||
opacity="0.15"
|
||||
className="animated-bg"
|
||||
sx={{
|
||||
background: `linear-gradient(135deg,
|
||||
${colors.secondary}, ${colors.primary}, ${colors.accent})`,
|
||||
backgroundSize: "400% 400%",
|
||||
animation: "gradientAnimation 8s ease infinite",
|
||||
"@keyframes gradientAnimation": {
|
||||
"0%": { backgroundPosition: "0% 50%" },
|
||||
"50%": { backgroundPosition: "100% 50%" },
|
||||
"100%": { backgroundPosition: "0% 50%" }
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
position="relative"
|
||||
textAlign="center"
|
||||
zIndex="1"
|
||||
>
|
||||
<Box
|
||||
width="70px"
|
||||
height="70px"
|
||||
mx="auto"
|
||||
mb={3}
|
||||
position="relative"
|
||||
>
|
||||
{isRecentlyJoined && (
|
||||
<Box
|
||||
position="absolute"
|
||||
top="-5px"
|
||||
left="-5px"
|
||||
right="-5px"
|
||||
bottom="-5px"
|
||||
borderRadius="full"
|
||||
zIndex="0"
|
||||
sx={{
|
||||
animation: "pulse 2s infinite",
|
||||
background: `radial-gradient(circle, ${colors.accent} 0%, transparent 70%)`,
|
||||
"@keyframes pulse": {
|
||||
"0%": { opacity: 0.7, transform: "scale(0.95)" },
|
||||
"70%": { opacity: 0, transform: "scale(1.2)" },
|
||||
"100%": { opacity: 0, transform: "scale(1.5)" }
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Battery icon with 6 segments */}
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
{/* Battery outline */}
|
||||
<rect
|
||||
x="2"
|
||||
y="6"
|
||||
width="18"
|
||||
height="12"
|
||||
rx="2"
|
||||
stroke={colors.primary}
|
||||
strokeWidth="1.5"
|
||||
fill="transparent"
|
||||
/>
|
||||
{/* Battery cap */}
|
||||
<path
|
||||
d="M20 10H22V14H20V10Z"
|
||||
fill={colors.primary}
|
||||
/>
|
||||
|
||||
{/* Battery segments - from lowest to highest */}
|
||||
<rect x="4" y="8" width="2.3" height="8" rx="1" fill={getSegmentFill(0)} />
|
||||
<rect x="7" y="8" width="2.3" height="8" rx="1" fill={getSegmentFill(1)} />
|
||||
<rect x="10" y="8" width="2.3" height="8" rx="1" fill={getSegmentFill(2)} />
|
||||
<rect x="13" y="8" width="2.3" height="8" rx="1" fill={getSegmentFill(3)} />
|
||||
<rect x="16" y="8" width="2.3" height="8" rx="1" fill={getSegmentFill(4)} />
|
||||
|
||||
{/* Lightning icon if recently joined or fully charged */}
|
||||
{(isRecentlyJoined || batteryLevel === 5) && (
|
||||
<path
|
||||
d="M11.5 7.5L8 12H11L9.5 16.5L13 12H10L11.5 7.5Z"
|
||||
fill={colors.accent}
|
||||
stroke={colors.primary}
|
||||
strokeWidth="0.3"
|
||||
opacity="0.9"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
</Box>
|
||||
<Box fontSize="sm" fontWeight="medium" color={colorMode === "light" ? "gray.800" : "white"}>
|
||||
{student.name || student.preferred_username}
|
||||
</Box>
|
||||
<Box
|
||||
fontSize="xs"
|
||||
mt={1}
|
||||
color={colors.primary}
|
||||
fontWeight="medium"
|
||||
>
|
||||
{isRecentlyJoined
|
||||
? t('journal.pl.lesson.recentlyJoined')
|
||||
: t(colors.text || 'journal.pl.lesson.attendance')}
|
||||
</Box>
|
||||
</Box>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
32
src/components/lesson/StudentList.tsx
Normal file
32
src/components/lesson/StudentList.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React from 'react'
|
||||
import { AnimatePresence } from 'framer-motion'
|
||||
import { User, Reaction } from '../../__data__/model'
|
||||
import { StudentList as StyledStudentList } from '../../pages/style'
|
||||
import { StudentCard } from './StudentCard'
|
||||
|
||||
interface StudentListProps {
|
||||
students: (User & { present?: boolean; recentlyPresent?: boolean })[]
|
||||
onAddUser: (user: User) => void
|
||||
studentReactions: Record<string, Reaction[]>
|
||||
}
|
||||
|
||||
export const StudentList: React.FC<StudentListProps> = ({
|
||||
students,
|
||||
onAddUser,
|
||||
studentReactions
|
||||
}) => {
|
||||
return (
|
||||
<StyledStudentList>
|
||||
<AnimatePresence initial={false}>
|
||||
{students.map((student) => (
|
||||
<StudentCard
|
||||
key={student.sub}
|
||||
student={student}
|
||||
onAddUser={onAddUser}
|
||||
reaction={studentReactions?.[student.sub]?.[0]}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</StyledStudentList>
|
||||
)
|
||||
}
|
||||
29
src/components/lesson/sstyle.ts
Normal file
29
src/components/lesson/sstyle.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import styled from '@emotion/styled'
|
||||
|
||||
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);
|
||||
}
|
||||
`
|
||||
@@ -113,31 +113,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);
|
||||
}
|
||||
`
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
import React, { useMemo } from 'react'
|
||||
import { sha256 } from 'js-sha256'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Box, useColorMode, Text } from '@chakra-ui/react'
|
||||
@@ -6,9 +6,9 @@ import { CheckCircleIcon, AddIcon } from '@chakra-ui/icons'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
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
|
||||
const REACTION_EMOJIS = {
|
||||
@@ -19,7 +19,7 @@ const REACTION_EMOJIS = {
|
||||
clap: '👏'
|
||||
}
|
||||
|
||||
export function getGravatarURL(email, user) {
|
||||
export function getGravatarURL(email) {
|
||||
if (!email) return void 0
|
||||
const address = String(email).trim().toLowerCase()
|
||||
const hash = sha256(address)
|
||||
@@ -30,62 +30,49 @@ export function getGravatarURL(email, user) {
|
||||
export const UserCard = ({
|
||||
student,
|
||||
present,
|
||||
onAddUser = undefined,
|
||||
wrapperAS = 'div',
|
||||
width,
|
||||
recentlyPresent = false,
|
||||
reactions = []
|
||||
reaction
|
||||
}: {
|
||||
student: User
|
||||
present: boolean
|
||||
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
|
||||
reactions?: Reaction[]
|
||||
reaction?: Reaction
|
||||
}) => {
|
||||
const { colorMode } = useColorMode();
|
||||
const { t } = useTranslation();
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const [visibleReactions, setVisibleReactions] = useState<Reaction[]>([]);
|
||||
const [showReaction, setShowReaction] = useState(false);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Filter reactions to only show this student's reactions
|
||||
const randomGravatarPath = useMemo(() => Math.random() * 1000, [])
|
||||
|
||||
// Обрабатываем изменение реакции
|
||||
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
|
||||
setVisibleReactions(prevReactions => [...prevReactions, ...newReactions]);
|
||||
|
||||
// Clear any existing timeout
|
||||
// Очищаем предыдущий таймер если он есть
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
// Set a new timeout
|
||||
// Устанавливаем новый таймер
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setVisibleReactions([]);
|
||||
setShowReaction(false);
|
||||
timeoutRef.current = null;
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up on unmount
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [reactions, student.sub, visibleReactions]);
|
||||
}, [reaction]);
|
||||
|
||||
return (
|
||||
<Wrapper
|
||||
@@ -96,7 +83,7 @@ export const UserCard = ({
|
||||
position="relative"
|
||||
>
|
||||
<Avatar
|
||||
src={imageError ? getGravatarURL(student.email, null) : (student.picture || getGravatarURL(student.email, null))}
|
||||
src={imageError ? getGravatarURL(student.email || randomGravatarPath) : (student.picture || getGravatarURL(student.email))}
|
||||
onError={() => {
|
||||
if (!imageError && student.picture) {
|
||||
setImageError(true);
|
||||
@@ -111,28 +98,20 @@ export const UserCard = ({
|
||||
</Box>
|
||||
)}
|
||||
</NameOverlay>
|
||||
{onAddUser && !present && (
|
||||
<AddMissedButton onClick={() => onAddUser(student)} aria-label={t('journal.pl.common.add')}>
|
||||
<AddIcon boxSize={3} />
|
||||
</AddMissedButton>
|
||||
)}
|
||||
|
||||
{/* Student reactions animation */}
|
||||
{/* Анимация реакции */}
|
||||
<AnimatePresence>
|
||||
{visibleReactions.map((reaction, index) => (
|
||||
{showReaction && reaction && (
|
||||
<motion.div
|
||||
key={reaction._id || index}
|
||||
initial={{ opacity: 0, scale: 0.5, x: 0, y: 0 }}
|
||||
animate={{ opacity: 1, scale: 1, x: 0, y: 0 }}
|
||||
key={reaction._id}
|
||||
initial={{ opacity: 0, scale: 0.5, y: 0 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.5, y: -20 }}
|
||||
transition={{
|
||||
duration: 0.5,
|
||||
delay: index * 0.1
|
||||
}}
|
||||
transition={{ duration: 0.5 }}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '10px', // Position at the top
|
||||
right: '10px', // Position at the right
|
||||
top: '10px',
|
||||
right: '10px',
|
||||
zIndex: 10,
|
||||
pointerEvents: 'none',
|
||||
display: 'flex',
|
||||
@@ -146,17 +125,17 @@ export const UserCard = ({
|
||||
title={t(`journal.pl.reactions.${reaction.reaction}`)}
|
||||
>
|
||||
<Text
|
||||
fontSize="3xl" // Increased size
|
||||
fontSize="3xl"
|
||||
sx={{
|
||||
filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.3))',
|
||||
transform: 'scale(1.2)', // Additional scaling
|
||||
transform: 'scale(1.2)',
|
||||
display: 'flex'
|
||||
}}
|
||||
>
|
||||
{REACTION_EMOJIS[reaction.reaction] || reaction.reaction}
|
||||
</Text>
|
||||
</motion.div>
|
||||
))}
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Wrapper>
|
||||
)
|
||||
|
||||
42
src/hooks/useAccessCode.ts
Normal file
42
src/hooks/useAccessCode.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useEffect } from 'react'
|
||||
import { getConfigValue } from '@brojs/cli'
|
||||
import { api } from '../__data__/api/api'
|
||||
import { isTeacher } from '../utils/user'
|
||||
import { User } from '../__data__/model'
|
||||
|
||||
// Using any for user type to avoid type errors with the store user object
|
||||
export const useAccessCode = (lessonId: string, user: any) => {
|
||||
const {
|
||||
isFetching,
|
||||
data: accessCode,
|
||||
isSuccess,
|
||||
refetch,
|
||||
} = api.useCreateAccessCodeQuery(
|
||||
{ lessonId },
|
||||
{
|
||||
skip: !isTeacher(user),
|
||||
pollingInterval:
|
||||
Number(getConfigValue('journal.polling-interval')) || 3000,
|
||||
skipPollingIfUnfocused: true,
|
||||
},
|
||||
)
|
||||
|
||||
const [manualAdd, manualAddRqst] = api.useManualAddStudentMutation()
|
||||
|
||||
useEffect(() => {
|
||||
if (manualAddRqst.isSuccess) {
|
||||
refetch()
|
||||
}
|
||||
}, [manualAddRqst.isSuccess, refetch])
|
||||
|
||||
const handleManualAdd = (user: User) => manualAdd({ lessonId, user })
|
||||
|
||||
return {
|
||||
isFetching,
|
||||
accessCode,
|
||||
isSuccess,
|
||||
refetch,
|
||||
manualAddRqst,
|
||||
handleManualAdd
|
||||
}
|
||||
}
|
||||
56
src/hooks/useQRCode.ts
Normal file
56
src/hooks/useQRCode.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useEffect, RefObject } from 'react'
|
||||
import QRCode from 'qrcode'
|
||||
|
||||
export const useQRCode = (
|
||||
canvasRef: RefObject<HTMLCanvasElement>,
|
||||
url: string,
|
||||
isFetching: boolean,
|
||||
isSuccess: boolean
|
||||
) => {
|
||||
useEffect(() => {
|
||||
if (!isFetching && isSuccess) {
|
||||
const generateQRCode = () => {
|
||||
if (!canvasRef.current) return;
|
||||
|
||||
// Получаем текущую ширину канваса, гарантируя квадратный QR-код
|
||||
const canvas = canvasRef.current;
|
||||
const containerWidth = canvas.clientWidth;
|
||||
|
||||
// Очищаем canvas перед новой генерацией
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Устанавливаем одинаковые размеры для ширины и высоты (1:1)
|
||||
canvas.width = containerWidth;
|
||||
canvas.height = containerWidth;
|
||||
|
||||
QRCode.toCanvas(
|
||||
canvas,
|
||||
url,
|
||||
{
|
||||
width: containerWidth,
|
||||
margin: 1 // Небольшой отступ для лучшей читаемости
|
||||
},
|
||||
function (error) {
|
||||
if (error) console.error(error)
|
||||
console.log('success!')
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Генерируем QR-код
|
||||
generateQRCode();
|
||||
|
||||
// Перегенерируем при изменении размера окна
|
||||
const handleResize = () => {
|
||||
generateQRCode();
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}
|
||||
}, [isFetching, isSuccess, url, canvasRef])
|
||||
}
|
||||
81
src/hooks/useStudentAttendance.ts
Normal file
81
src/hooks/useStudentAttendance.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||
|
||||
import { AccessCode, BaseResponse, User } from '../__data__/model'
|
||||
|
||||
export const useStudentAttendance = (
|
||||
accessCode: BaseResponse<AccessCode>,
|
||||
allStudents: any
|
||||
) => {
|
||||
// Создаем ref для отслеживания ранее присутствовавших студентов
|
||||
const prevPresentStudentsRef = useRef(new Set<string>())
|
||||
|
||||
// Добавляем состояние для отслеживания пульсации
|
||||
const [isPulsing, setIsPulsing] = useState(false)
|
||||
// Отслеживаем предыдущее количество студентов
|
||||
const prevStudentCountRef = useRef(0)
|
||||
|
||||
// Эффект для обнаружения и обновления новых присутствующих студентов
|
||||
useEffect(() => {
|
||||
if (accessCode?.body) {
|
||||
const currentPresent = new Set(accessCode.body.lesson.students.map(s => s.sub))
|
||||
|
||||
// Проверяем, изменилось ли количество студентов
|
||||
const currentCount = accessCode.body.lesson.students.length;
|
||||
if (prevStudentCountRef.current !== currentCount && prevStudentCountRef.current > 0) {
|
||||
// Запускаем эффект пульсации
|
||||
setIsPulsing(true);
|
||||
// Сбрасываем эффект через 1.5 секунды
|
||||
setTimeout(() => {
|
||||
setIsPulsing(false);
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
// Обновляем предыдущее количество
|
||||
prevStudentCountRef.current = currentCount;
|
||||
|
||||
// Очищаем флаги предыдущего состояния после задержки
|
||||
const timeoutId = setTimeout(() => {
|
||||
prevPresentStudentsRef.current = currentPresent
|
||||
}, 3000)
|
||||
|
||||
return () => clearTimeout(timeoutId)
|
||||
}
|
||||
}, [accessCode])
|
||||
|
||||
const studentsArr = useMemo(() => {
|
||||
let allStudentsList: (User & { present?: boolean; recentlyPresent?: boolean })[] = [
|
||||
...(allStudents.data?.body || []),
|
||||
].map((st) => ({ ...st, present: false, recentlyPresent: false }))
|
||||
let presentStudents: (User & { present?: boolean })[] = [
|
||||
...(accessCode?.body?.lesson?.students || []),
|
||||
]
|
||||
|
||||
// Находим новых студентов по сравнению с предыдущим состоянием
|
||||
const currentPresent = new Set(presentStudents.map(s => s.sub))
|
||||
const newlyPresent = [...currentPresent].filter(id => !prevPresentStudentsRef.current.has(id))
|
||||
|
||||
while (presentStudents.length) {
|
||||
const student = presentStudents.pop()
|
||||
|
||||
const present = allStudentsList.find((st) => st.sub === student.sub)
|
||||
|
||||
if (present) {
|
||||
present.present = true
|
||||
present.recentlyPresent = newlyPresent.includes(student.sub)
|
||||
} else {
|
||||
allStudentsList.push({
|
||||
...student,
|
||||
present: true,
|
||||
recentlyPresent: newlyPresent.includes(student.sub)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return allStudentsList
|
||||
}, [accessCode?.body, allStudents.data, prevPresentStudentsRef.current])
|
||||
|
||||
return {
|
||||
studentsArr,
|
||||
isPulsing
|
||||
}
|
||||
}
|
||||
40
src/hooks/useStudentReactions.ts
Normal file
40
src/hooks/useStudentReactions.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Reaction } from '../__data__/model'
|
||||
|
||||
export const useStudentReactions = (accessCode: any) => {
|
||||
// Отслеживаем предыдущие реакции для определения новых
|
||||
const prevReactionsRef = useRef<Record<string, Reaction[]>>({})
|
||||
// Храним актуальные реакции студентов
|
||||
const [studentReactions, setStudentReactions] = useState<Record<string, Reaction[]>>({})
|
||||
|
||||
// Эффект для обработки новых реакций
|
||||
useEffect(() => {
|
||||
if (accessCode?.body?.lesson?.studentReactions) {
|
||||
const reactions = accessCode.body.lesson.studentReactions;
|
||||
|
||||
// Группируем реакции по sub (идентификатору студента)
|
||||
const groupedReactions: Record<string, Reaction[]> = {};
|
||||
|
||||
reactions.forEach(reaction => {
|
||||
if (!groupedReactions[reaction.sub]) {
|
||||
groupedReactions[reaction.sub] = [];
|
||||
}
|
||||
groupedReactions[reaction.sub].push(reaction);
|
||||
});
|
||||
|
||||
// Обновляем отображаемые реакции
|
||||
setStudentReactions(groupedReactions);
|
||||
|
||||
// Обновляем предыдущие реакции после небольшой задержки
|
||||
const updatePrevReactionsTimeout = setTimeout(() => {
|
||||
prevReactionsRef.current = groupedReactions;
|
||||
}, 1000);
|
||||
|
||||
return () => clearTimeout(updatePrevReactionsTimeout);
|
||||
}
|
||||
}, [accessCode?.body?.lesson?.studentReactions]);
|
||||
|
||||
return {
|
||||
studentReactions
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
import React, { useEffect, useRef, useMemo, useState } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import QRCode from 'qrcode'
|
||||
import React, { useMemo } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { sha256 } from 'js-sha256'
|
||||
import { getConfigValue, getNavigationValue } from '@brojs/cli'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { getNavigationValue } from '@brojs/cli'
|
||||
import {
|
||||
Box,
|
||||
Container,
|
||||
@@ -11,23 +9,24 @@ import {
|
||||
Heading,
|
||||
Stack,
|
||||
useColorMode,
|
||||
Flex,
|
||||
} from '@chakra-ui/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { api } from '../__data__/api/api'
|
||||
import { User, Reaction } from '../__data__/model'
|
||||
import { UserCard } from '../components/user-card'
|
||||
import { formatDate } from '../utils/dayjs-config'
|
||||
import { useSetBreadcrumbs } from '../components'
|
||||
|
||||
import {
|
||||
QRCanvas,
|
||||
StudentList,
|
||||
} from './style'
|
||||
import { useAppSelector } from '../__data__/store'
|
||||
import { isTeacher } from '../utils/user'
|
||||
|
||||
// Custom hooks
|
||||
import { useAccessCode } from '../hooks/useAccessCode'
|
||||
import { useStudentAttendance } from '../hooks/useStudentAttendance'
|
||||
import { useStudentReactions } from '../hooks/useStudentReactions'
|
||||
|
||||
// Components
|
||||
import { QRCodeDisplay } from '../components/lesson/QRCodeDisplay'
|
||||
import { AttendanceStats } from '../components/lesson/AttendanceStats'
|
||||
import { StudentList } from '../components/lesson/StudentList'
|
||||
|
||||
export function getGravatarURL(email, user) {
|
||||
if (!email) return void 0
|
||||
const address = String(email).trim().toLowerCase()
|
||||
@@ -39,7 +38,6 @@ export function getGravatarURL(email, user) {
|
||||
|
||||
const LessonDetail = () => {
|
||||
const { lessonId, courseId } = useParams()
|
||||
const canvRef = useRef(null)
|
||||
const user = useAppSelector((s) => s.user)
|
||||
const { t } = useTranslation()
|
||||
const { colorMode } = useColorMode()
|
||||
@@ -64,202 +62,25 @@ const LessonDetail = () => {
|
||||
}
|
||||
])
|
||||
|
||||
// Создаем ref для отслеживания ранее присутствовавших студентов
|
||||
const prevPresentStudentsRef = useRef(new Set<string>())
|
||||
|
||||
// Добавляем состояние для отслеживания пульсации
|
||||
const [isPulsing, setIsPulsing] = useState(false)
|
||||
// Отслеживаем предыдущее количество студентов
|
||||
const prevStudentCountRef = useRef(0)
|
||||
// Отслеживаем предыдущие реакции для определения новых
|
||||
const prevReactionsRef = useRef<Record<string, Reaction[]>>({})
|
||||
// Храним актуальные реакции студентов
|
||||
const [studentReactions, setStudentReactions] = useState<Record<string, Reaction[]>>({})
|
||||
|
||||
// Используем кастомные хуки
|
||||
const {
|
||||
isFetching,
|
||||
data: accessCode,
|
||||
accessCode,
|
||||
isSuccess,
|
||||
refetch,
|
||||
} = api.useCreateAccessCodeQuery(
|
||||
{ lessonId },
|
||||
{
|
||||
skip: !isTeacher(user),
|
||||
pollingInterval:
|
||||
Number(getConfigValue('journal.polling-interval')) || 3000,
|
||||
skipPollingIfUnfocused: true,
|
||||
},
|
||||
)
|
||||
handleManualAdd
|
||||
} = useAccessCode(lessonId, user)
|
||||
|
||||
const AllStudents = api.useCourseAllStudentsQuery(courseId)
|
||||
const [manualAdd, manualAddRqst] = api.useManualAddStudentMutation()
|
||||
|
||||
const { studentsArr, isPulsing } = useStudentAttendance(accessCode, AllStudents)
|
||||
const { studentReactions } = useStudentReactions(accessCode)
|
||||
|
||||
// Создаем URL для QR-кода
|
||||
const userUrl = useMemo(
|
||||
() => `${location.origin}/journal/u/${lessonId}/${accessCode?.body?._id}`,
|
||||
[accessCode, lessonId],
|
||||
)
|
||||
|
||||
// Эффект для обнаружения и обновления новых присутствующих студентов
|
||||
useEffect(() => {
|
||||
if (accessCode?.body) {
|
||||
const currentPresent = new Set(accessCode.body.lesson.students.map(s => s.sub))
|
||||
|
||||
// Проверяем, изменилось ли количество студентов
|
||||
const currentCount = accessCode.body.lesson.students.length;
|
||||
if (prevStudentCountRef.current !== currentCount && prevStudentCountRef.current > 0) {
|
||||
// Запускаем эффект пульсации
|
||||
setIsPulsing(true);
|
||||
// Сбрасываем эффект через 1.5 секунды
|
||||
setTimeout(() => {
|
||||
setIsPulsing(false);
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
// Обновляем предыдущее количество
|
||||
prevStudentCountRef.current = currentCount;
|
||||
|
||||
// Очищаем флаги предыдущего состояния после задержки
|
||||
const timeoutId = setTimeout(() => {
|
||||
prevPresentStudentsRef.current = currentPresent
|
||||
}, 3000)
|
||||
|
||||
return () => clearTimeout(timeoutId)
|
||||
}
|
||||
}, [accessCode])
|
||||
|
||||
// Эффект для обработки новых реакций
|
||||
useEffect(() => {
|
||||
if (accessCode?.body?.lesson?.reactions) {
|
||||
const reactions = accessCode.body.lesson.reactions;
|
||||
|
||||
// Группируем реакции по sub (идентификатору студента)
|
||||
const groupedReactions: Record<string, Reaction[]> = {};
|
||||
|
||||
reactions.forEach(reaction => {
|
||||
if (!groupedReactions[reaction.sub]) {
|
||||
groupedReactions[reaction.sub] = [];
|
||||
}
|
||||
|
||||
// Добавляем только новые реакции
|
||||
const isNewReaction = !prevReactionsRef.current[reaction.sub]?.some(
|
||||
r => r._id === reaction._id
|
||||
);
|
||||
|
||||
if (isNewReaction) {
|
||||
groupedReactions[reaction.sub].push(reaction);
|
||||
}
|
||||
});
|
||||
|
||||
// Обновляем отображаемые реакции
|
||||
setStudentReactions(groupedReactions);
|
||||
|
||||
// Обновляем предыдущие реакции
|
||||
prevReactionsRef.current = { ...groupedReactions };
|
||||
|
||||
// Сбрасываем отображаемые реакции через некоторое время
|
||||
const clearReactionsTimeout = setTimeout(() => {
|
||||
setStudentReactions({});
|
||||
}, 5000);
|
||||
|
||||
return () => clearTimeout(clearReactionsTimeout);
|
||||
}
|
||||
}, [accessCode?.body?.lesson?.reactions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (manualAddRqst.isSuccess) {
|
||||
refetch()
|
||||
}
|
||||
}, [manualAddRqst.isSuccess])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFetching && isSuccess) {
|
||||
const generateQRCode = () => {
|
||||
if (!canvRef.current) return;
|
||||
|
||||
// Получаем текущую ширину канваса, гарантируя квадратный QR-код
|
||||
const canvas = canvRef.current;
|
||||
const containerWidth = canvas.clientWidth;
|
||||
|
||||
// Очищаем canvas перед новой генерацией
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Устанавливаем одинаковые размеры для ширины и высоты (1:1)
|
||||
canvas.width = containerWidth;
|
||||
canvas.height = containerWidth;
|
||||
|
||||
QRCode.toCanvas(
|
||||
canvas,
|
||||
userUrl,
|
||||
{
|
||||
width: containerWidth,
|
||||
margin: 1 // Небольшой отступ для лучшей читаемости
|
||||
},
|
||||
function (error) {
|
||||
if (error) console.error(error)
|
||||
console.log('success!')
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Генерируем QR-код
|
||||
generateQRCode();
|
||||
|
||||
// Перегенерируем при изменении размера окна
|
||||
const handleResize = () => {
|
||||
generateQRCode();
|
||||
};
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}
|
||||
}, [isFetching, isSuccess, userUrl])
|
||||
|
||||
const studentsArr = useMemo(() => {
|
||||
let allStudents: (User & { present?: boolean; recentlyPresent?: boolean })[] = [
|
||||
...(AllStudents.data?.body || []),
|
||||
].map((st) => ({ ...st, present: false, recentlyPresent: false }))
|
||||
let presentStudents: (User & { present?: boolean })[] = [
|
||||
...(accessCode?.body.lesson.students || []),
|
||||
]
|
||||
|
||||
// Находим новых студентов по сравнению с предыдущим состоянием
|
||||
const currentPresent = new Set(presentStudents.map(s => s.sub))
|
||||
const newlyPresent = [...currentPresent].filter(id => !prevPresentStudentsRef.current.has(id))
|
||||
|
||||
while (presentStudents.length) {
|
||||
const student = presentStudents.pop()
|
||||
|
||||
const present = allStudents.find((st) => st.sub === student.sub)
|
||||
|
||||
if (present) {
|
||||
present.present = true
|
||||
present.recentlyPresent = newlyPresent.includes(student.sub)
|
||||
} else {
|
||||
allStudents.push({
|
||||
...student,
|
||||
present: true,
|
||||
recentlyPresent: newlyPresent.includes(student.sub)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Removing the sorting to prevent reordering animation
|
||||
return allStudents
|
||||
}, [accessCode?.body, AllStudents.data, prevPresentStudentsRef.current])
|
||||
|
||||
// Функция для определения цвета на основе посещаемости
|
||||
const getAttendanceColor = (attendance: number, total: number) => {
|
||||
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 > 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 > 20) return { bg: 'orange.100', color: 'orange.800', dark: { bg: 'orange.800', color: 'orange.100' } }
|
||||
return { bg: 'red.100', color: 'red.800', dark: { bg: 'red.800', color: 'red.100' } }
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container maxW="2280px">
|
||||
@@ -268,7 +89,6 @@ const LessonDetail = () => {
|
||||
{t('journal.pl.lesson.topicTitle')}
|
||||
</Heading>
|
||||
<Box as="span">{accessCode?.body?.lesson?.name}</Box>
|
||||
|
||||
</VStack>
|
||||
<Stack spacing="8" direction={{ base: "column", md: "row" }} mt={6}>
|
||||
<Box
|
||||
@@ -281,56 +101,19 @@ const LessonDetail = () => {
|
||||
position="sticky"
|
||||
top="20px"
|
||||
zIndex="2"
|
||||
><Box pb={3}>
|
||||
{formatDate(accessCode?.body?.lesson?.date, t('journal.pl.lesson.dateFormat'))}{' '}
|
||||
{t('journal.pl.common.marked')} -
|
||||
{AllStudents.isSuccess && (
|
||||
<Box
|
||||
as="span"
|
||||
px={2}
|
||||
py={1}
|
||||
ml={2}
|
||||
borderRadius="md"
|
||||
fontWeight="bold"
|
||||
bg={getAttendanceColor(
|
||||
accessCode?.body?.lesson?.students?.length || 0,
|
||||
AllStudents?.data?.body?.length || 1
|
||||
).bg}
|
||||
color={getAttendanceColor(
|
||||
accessCode?.body?.lesson?.students?.length || 0,
|
||||
AllStudents?.data?.body?.length || 1
|
||||
).color}
|
||||
_dark={{
|
||||
bg: getAttendanceColor(
|
||||
accessCode?.body?.lesson?.students?.length || 0,
|
||||
AllStudents?.data?.body?.length || 1
|
||||
).dark.bg,
|
||||
color: getAttendanceColor(
|
||||
accessCode?.body?.lesson?.students?.length || 0,
|
||||
AllStudents?.data?.body?.length || 1
|
||||
).dark.color
|
||||
}}
|
||||
position="relative"
|
||||
animation={isPulsing ? "pulse 1.5s ease-in-out" : "none"}
|
||||
sx={{
|
||||
'@keyframes pulse': {
|
||||
'0%': { transform: 'scale(1)' },
|
||||
'50%': { transform: 'scale(1.15)', boxShadow: '0 0 10px rgba(66, 153, 225, 0.7)' },
|
||||
'100%': { transform: 'scale(1)' }
|
||||
}
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
<a href={userUrl}>
|
||||
<QRCanvas ref={canvRef} />
|
||||
</a>
|
||||
<AttendanceStats
|
||||
lessonDate={accessCode?.body?.lesson?.date}
|
||||
studentsPresent={accessCode?.body?.lesson?.students?.length || 0}
|
||||
totalStudents={AllStudents?.data?.body?.length || 0}
|
||||
isLoading={!AllStudents.isSuccess}
|
||||
isPulsing={isPulsing}
|
||||
/>
|
||||
<QRCodeDisplay
|
||||
url={userUrl}
|
||||
isFetching={isFetching}
|
||||
isSuccess={isSuccess}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
flex={1}
|
||||
@@ -339,174 +122,13 @@ const LessonDetail = () => {
|
||||
bg={colorMode === "light" ? "gray.50" : "gray.700"}
|
||||
boxShadow="md"
|
||||
>
|
||||
<StudentList>
|
||||
{isTeacher(user) && (
|
||||
<AnimatePresence initial={false}>
|
||||
{studentsArr.map((student) => (
|
||||
<motion.li
|
||||
key={student.sub}
|
||||
animate={{
|
||||
rotateY: student.present ? 0 : 180,
|
||||
boxShadow: student.recentlyPresent
|
||||
? ['0 0 0 0 rgba(66, 153, 225, 0)', '0 0 20px 5px rgba(66, 153, 225, 0.7)', '0 0 0 0 rgba(66, 153, 225, 0)']
|
||||
: '0 0 0 0 rgba(0, 0, 0, 0)'
|
||||
}}
|
||||
transition={{
|
||||
rotateY: { type: "spring", stiffness: 300, damping: 20 },
|
||||
boxShadow: {
|
||||
repeat: student.recentlyPresent ? 3 : 0,
|
||||
duration: 1.5
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
transformStyle: "preserve-3d",
|
||||
perspective: "1000px",
|
||||
aspectRatio: "1",
|
||||
width: "100%",
|
||||
display: "block"
|
||||
}}
|
||||
>
|
||||
{/* Front side - visible when present */}
|
||||
<Box
|
||||
position="relative"
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={{
|
||||
transformStyle: "preserve-3d"
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
position="absolute"
|
||||
top="0"
|
||||
left="0"
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={{
|
||||
backfaceVisibility: "hidden",
|
||||
transform: "rotateY(0deg)",
|
||||
zIndex: student.present ? 1 : 0
|
||||
}}
|
||||
>
|
||||
<UserCard
|
||||
wrapperAS="div"
|
||||
student={student}
|
||||
present={student.present}
|
||||
recentlyPresent={student.recentlyPresent}
|
||||
onAddUser={(user: User) => manualAdd({ lessonId, user })}
|
||||
reactions={studentReactions[student.sub] || []}
|
||||
<StudentList
|
||||
students={studentsArr}
|
||||
onAddUser={handleManualAdd}
|
||||
studentReactions={studentReactions}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Back side - visible when not present */}
|
||||
<Flex
|
||||
position="absolute"
|
||||
top="0"
|
||||
left="0"
|
||||
width="100%"
|
||||
height="100%"
|
||||
bg={colorMode === "light" ? "gray.100" : "gray.600"}
|
||||
borderRadius="12px"
|
||||
align="center"
|
||||
justify="center"
|
||||
p={4}
|
||||
overflow="hidden"
|
||||
style={{
|
||||
backfaceVisibility: "hidden",
|
||||
transform: "rotateY(180deg)",
|
||||
zIndex: student.present ? 0 : 1,
|
||||
aspectRatio: "1"
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
position="absolute"
|
||||
top="0"
|
||||
left="0"
|
||||
right="0"
|
||||
bottom="0"
|
||||
opacity="0.2"
|
||||
className="animated-bg"
|
||||
sx={{
|
||||
background: `linear-gradient(135deg,
|
||||
${colorMode === "light" ? "#e3f2fd, #bbdefb, #90caf9" : "#1a365d, #2a4365, #2c5282"})`,
|
||||
backgroundSize: "400% 400%",
|
||||
animation: "gradientAnimation 8s ease infinite",
|
||||
"@keyframes gradientAnimation": {
|
||||
"0%": { backgroundPosition: "0% 50%" },
|
||||
"50%": { backgroundPosition: "100% 50%" },
|
||||
"100%": { backgroundPosition: "0% 50%" }
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Box
|
||||
position="relative"
|
||||
textAlign="center"
|
||||
zIndex="1"
|
||||
>
|
||||
<Box
|
||||
width="60px"
|
||||
height="60px"
|
||||
mx="auto"
|
||||
mb={2}
|
||||
sx={{
|
||||
animation: "float 3s ease-in-out infinite",
|
||||
"@keyframes float": {
|
||||
"0%": { transform: "translateY(0px)" },
|
||||
"50%": { transform: "translateY(-10px)" },
|
||||
"100%": { transform: "translateY(0px)" }
|
||||
}
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
{/* Академическая шапочка */}
|
||||
<path
|
||||
d="M12 2L2 6.5L12 11L22 6.5L12 2Z"
|
||||
fill={colorMode === "light" ? "#3182CE" : "#63B3ED"}
|
||||
/>
|
||||
<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"
|
||||
fill={colorMode === "light" ? "#2C5282" : "#4299E1"}
|
||||
/>
|
||||
<path
|
||||
d="M21 7V14M21 14L19 16M21 14L23 16"
|
||||
stroke={colorMode === "light" ? "#2C5282" : "#4299E1"}
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
|
||||
{/* Лицо студента */}
|
||||
<circle
|
||||
cx="12"
|
||||
cy="15"
|
||||
r="2.5"
|
||||
fill={colorMode === "light" ? "#3182CE" : "#63B3ED"}
|
||||
/>
|
||||
|
||||
{/* Тело студента */}
|
||||
<path
|
||||
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"}
|
||||
/>
|
||||
</svg>
|
||||
</Box>
|
||||
<Box fontSize="sm" fontWeight="medium">
|
||||
{student.name || student.preferred_username}
|
||||
</Box>
|
||||
<Box
|
||||
fontSize="xs"
|
||||
opacity={0.8}
|
||||
color={colorMode === "light" ? "gray.600" : "gray.300"}
|
||||
>
|
||||
{t('journal.pl.lesson.notMarked')}
|
||||
</Box>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Box>
|
||||
</motion.li>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</StudentList>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Container>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useForm, Controller } from 'react-hook-form'
|
||||
import {
|
||||
Box,
|
||||
@@ -24,9 +24,14 @@ import {
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
SkeletonText,
|
||||
useStyleConfig
|
||||
useStyleConfig,
|
||||
Select,
|
||||
Wrap,
|
||||
WrapItem,
|
||||
IconButton,
|
||||
Center
|
||||
} 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 { FaRobot } from 'react-icons/fa'
|
||||
import dayjs from 'dayjs'
|
||||
@@ -39,6 +44,7 @@ import { ErrorSpan } from '../style'
|
||||
interface NewLessonForm {
|
||||
name: string
|
||||
date: string
|
||||
time: string
|
||||
}
|
||||
|
||||
interface LessonFormProps {
|
||||
@@ -54,6 +60,7 @@ interface LessonFormProps {
|
||||
onSelectAiSuggestion?: (suggestion: any) => void // Обработчик выбора предложения
|
||||
selectedAiSuggestion?: any // Выбранное предложение
|
||||
onRetryAiGeneration?: () => void // Функция для повторного запуска генерации
|
||||
existingLessons?: Array<{ date: string; name: string }> // Добавляем новый проп
|
||||
}
|
||||
|
||||
export const LessonForm = ({
|
||||
@@ -68,7 +75,8 @@ export const LessonForm = ({
|
||||
isLoadingAiSuggestions = false,
|
||||
onSelectAiSuggestion = () => {},
|
||||
selectedAiSuggestion,
|
||||
onRetryAiGeneration = () => {}
|
||||
onRetryAiGeneration = () => {},
|
||||
existingLessons
|
||||
}: LessonFormProps) => {
|
||||
const { t } = useTranslation()
|
||||
const isAiSuggested = lesson && !lesson._id && !lesson.id
|
||||
@@ -131,6 +139,257 @@ export const LessonForm = ({
|
||||
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 (
|
||||
<Card align="left" bg={isAiSuggested ? aiHighlightColor : undefined}>
|
||||
<CardHeader display="flex">
|
||||
@@ -160,23 +419,68 @@ export const LessonForm = ({
|
||||
control={control}
|
||||
name="date"
|
||||
rules={{ required: t('journal.pl.common.required') }}
|
||||
render={({ field }) => (
|
||||
render={({ field }) => {
|
||||
const [currentDate = '', currentTime = '00:00:00'] = field.value.split('T');
|
||||
const currentTimeShort = currentTime.split(':').slice(0, 2).join(':');
|
||||
const selectedDate = new Date(currentDate);
|
||||
|
||||
// Получаем существующие лекции из пропсов компонента
|
||||
const existingLessons2 = existingLessons?.map(lesson => lesson.date) || [];
|
||||
|
||||
return (
|
||||
<FormControl>
|
||||
<FormLabel>{t('journal.pl.lesson.form.date')}</FormLabel>
|
||||
<Input
|
||||
{...field}
|
||||
required={false}
|
||||
placeholder={t('journal.pl.lesson.form.datePlaceholder')}
|
||||
size="md"
|
||||
type="datetime-local"
|
||||
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4}>
|
||||
{/* Календарь */}
|
||||
<Box>
|
||||
<Calendar
|
||||
selectedDate={selectedDate}
|
||||
existingLessons={existingLessons2}
|
||||
onSelectDate={(date) => {
|
||||
const formattedDate = dateToCalendarFormat(date.toISOString()).split('T')[0];
|
||||
field.onChange(`${formattedDate}T${currentTimeShort}:00`);
|
||||
}}
|
||||
/>
|
||||
{errors.date ? (
|
||||
<FormErrorMessage>{errors.date?.message}</FormErrorMessage>
|
||||
) : (
|
||||
<FormHelperText>{t('journal.pl.lesson.form.dateTime')}</FormHelperText>
|
||||
)}
|
||||
</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
|
||||
|
||||
@@ -359,6 +359,10 @@ const LessonList = () => {
|
||||
onSelectAiSuggestion={handleSelectAiSuggestion}
|
||||
selectedAiSuggestion={suggestedLessonToCreate}
|
||||
onRetryAiGeneration={handleRetryAiGeneration}
|
||||
existingLessons={data?.body?.map(lesson => ({
|
||||
date: lesson.date,
|
||||
name: lesson.name
|
||||
}))}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
|
||||
@@ -63,28 +63,28 @@ const UserPage = () => {
|
||||
// Эффект для поэтапного появления карточек студентов
|
||||
useEffect(() => {
|
||||
if (ls.data?.body?.students?.length) {
|
||||
// Сначала очищаем список
|
||||
setAnimatedStudents([])
|
||||
// Обновляем существующих студентов с сохранением их анимации
|
||||
setAnimatedStudents(prevStudents => {
|
||||
const newStudents = ls.data.body.students.map(student => {
|
||||
// Находим существующего студента
|
||||
const existingStudent = prevStudents.find(p => p.sub === student.sub);
|
||||
// Сохраняем флаг isNew если студент уже существует
|
||||
return {
|
||||
...student,
|
||||
isNew: existingStudent ? existingStudent.isNew : true
|
||||
};
|
||||
});
|
||||
|
||||
// Затем постепенно добавляем студентов для красивой анимации
|
||||
const students = [...ls.data.body.students]
|
||||
const addStudentWithDelay = (index) => {
|
||||
if (index < students.length) {
|
||||
setAnimatedStudents(prev => [...prev, {...students[index], isNew: true}])
|
||||
|
||||
// Для следующего студента
|
||||
setTimeout(() => {
|
||||
addStudentWithDelay(index + 1)
|
||||
}, 100) // Уменьшенная задержка для более плавной анимации
|
||||
}
|
||||
// Если количество студентов не изменилось, сохраняем текущий массив
|
||||
if (prevStudents.length === newStudents.length &&
|
||||
prevStudents.every(student => newStudents.find(n => n.sub === student.sub))) {
|
||||
return prevStudents;
|
||||
}
|
||||
|
||||
// Запускаем процесс добавления с небольшой задержкой для лучшего UX
|
||||
setTimeout(() => {
|
||||
addStudentWithDelay(0)
|
||||
}, 300)
|
||||
return newStudents;
|
||||
});
|
||||
}
|
||||
}, [ls.data?.body?.students])
|
||||
}, [ls.data?.body?.students, ls.data?.body?.studentReactions])
|
||||
|
||||
// Эффект для сброса флага "новизны" студентов
|
||||
useEffect(() => {
|
||||
@@ -277,7 +277,7 @@ const UserPage = () => {
|
||||
student={student}
|
||||
present={true}
|
||||
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>
|
||||
))}
|
||||
|
||||
10
src/utils/attendance-colors.ts
Normal file
10
src/utils/attendance-colors.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// Функция для определения цвета на основе посещаемости
|
||||
export const getAttendanceColor = (attendance: number, total: number) => {
|
||||
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 > 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 > 20) return { bg: 'orange.100', color: 'orange.800', dark: { bg: 'orange.800', color: 'orange.100' } }
|
||||
return { bg: 'red.100', color: 'red.800', dark: { bg: 'red.800', color: 'red.100' } }
|
||||
}
|
||||
@@ -35,7 +35,7 @@ function readAndModifyJson(filePath) {
|
||||
jsonContent.body.forEach((lesson) => {
|
||||
// Случайная дата в пределах последних 3 месяцев
|
||||
const randomDate = new Date();
|
||||
randomDate.setMonth(randomDate.getMonth() - Math.random() * 3);
|
||||
randomDate.setDate(randomDate.getDate() - Math.random() * 30);
|
||||
lesson.date = randomDate.toISOString();
|
||||
lesson.created = new Date(randomDate.getTime() - 86400000).toISOString(); // Создан за день до даты
|
||||
});
|
||||
|
||||
@@ -590,8 +590,8 @@
|
||||
"sub": "developer",
|
||||
"email": "email@email.ml"
|
||||
},
|
||||
"startDt": "2024-08-25T17:40:17.814Z",
|
||||
"created": "2024-08-25T17:40:17.814Z",
|
||||
"startDt": "2024-08-25T17:30:00.000Z",
|
||||
"created": "2024-08-25T17:40:17.000Z",
|
||||
"examWithJury2": {
|
||||
"_id": "66cf3d3f4637d420d6271451",
|
||||
"name": "Хакатон",
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"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",
|
||||
"id": "661e7f4f69f40b0ebebcd5e4"
|
||||
},
|
||||
@@ -37,7 +37,7 @@
|
||||
"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",
|
||||
"id": "66af1e60a0eef5a89f99aa94"
|
||||
},
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"picture": "https://lh3.googleusercontent.com/a/ACg8ocJUtJBAVBm642AxoGpMDDMV8CPu3MEoLjU3hmO7oisG=s96-c"
|
||||
}
|
||||
],
|
||||
"reactions": [
|
||||
"studentReactions": [
|
||||
{
|
||||
"_id": "r1d73f22-c9ba-422a-b572-c59e515a2901",
|
||||
"sub": "fcde3f22-d9ba-412a-a572-c59e515a290f",
|
||||
|
||||
Reference in New Issue
Block a user