bettary power attendance
This commit is contained in:
parent
870ac5348b
commit
bda0d1673e
@ -93,6 +93,14 @@
|
|||||||
"journal.pl.lesson.noStudents": "No Students Yet",
|
"journal.pl.lesson.noStudents": "No Students Yet",
|
||||||
"journal.pl.lesson.waitForStudents": "Students who attend the lesson will appear here",
|
"journal.pl.lesson.waitForStudents": "Students who attend the lesson will appear here",
|
||||||
"journal.pl.lesson.notMarked": "Not yet marked",
|
"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.thumbs_up": "Thumbs up",
|
||||||
"journal.pl.reactions.heart": "Heart",
|
"journal.pl.reactions.heart": "Heart",
|
||||||
|
@ -90,6 +90,14 @@
|
|||||||
"journal.pl.lesson.noStudents": "Пока нет студентов",
|
"journal.pl.lesson.noStudents": "Пока нет студентов",
|
||||||
"journal.pl.lesson.waitForStudents": "Студенты, посетившие занятие, появятся здесь",
|
"journal.pl.lesson.waitForStudents": "Студенты, посетившие занятие, появятся здесь",
|
||||||
"journal.pl.lesson.notMarked": "Не отмечен",
|
"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.thumbs_up": "Палец вверх",
|
||||||
"journal.pl.reactions.heart": "Сердце",
|
"journal.pl.reactions.heart": "Сердце",
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
175
src/components/lesson/StudentCard.tsx
Normal file
175
src/components/lesson/StudentCard.tsx
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
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}
|
||||||
|
onAddUser={onAddUser}
|
||||||
|
reaction={reaction}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Battery indicator in corner */}
|
||||||
|
<BatteryIndicator student={student} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Back side */}
|
||||||
|
<StudentCardBack student={student} />
|
||||||
|
</Box>
|
||||||
|
</motion.li>
|
||||||
|
)
|
||||||
|
}
|
274
src/components/lesson/StudentCardBack.tsx
Normal file
274
src/components/lesson/StudentCardBack.tsx
Normal file
@ -0,0 +1,274 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Box, Flex, useColorMode } from '@chakra-ui/react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { User } from '../../__data__/model'
|
||||||
|
|
||||||
|
interface StudentCardBackProps {
|
||||||
|
student: User & { present?: boolean; recentlyPresent?: boolean }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StudentCardBack: React.FC<StudentCardBackProps> = ({ student }) => {
|
||||||
|
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"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
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 React, { useMemo } from 'react'
|
||||||
import { useParams, Link } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
import QRCode from 'qrcode'
|
|
||||||
import { sha256 } from 'js-sha256'
|
import { sha256 } from 'js-sha256'
|
||||||
import { getConfigValue, getNavigationValue } from '@brojs/cli'
|
import { getNavigationValue } from '@brojs/cli'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Container,
|
Container,
|
||||||
@ -11,23 +9,24 @@ import {
|
|||||||
Heading,
|
Heading,
|
||||||
Stack,
|
Stack,
|
||||||
useColorMode,
|
useColorMode,
|
||||||
Flex,
|
|
||||||
} from '@chakra-ui/react'
|
} from '@chakra-ui/react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
import { api } from '../__data__/api/api'
|
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 { useSetBreadcrumbs } from '../components'
|
||||||
|
|
||||||
import {
|
|
||||||
QRCanvas,
|
|
||||||
StudentList,
|
|
||||||
} from './style'
|
|
||||||
import { useAppSelector } from '../__data__/store'
|
import { useAppSelector } from '../__data__/store'
|
||||||
import { isTeacher } from '../utils/user'
|
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) {
|
export function getGravatarURL(email, user) {
|
||||||
if (!email) return void 0
|
if (!email) return void 0
|
||||||
const address = String(email).trim().toLowerCase()
|
const address = String(email).trim().toLowerCase()
|
||||||
@ -39,7 +38,6 @@ export function getGravatarURL(email, user) {
|
|||||||
|
|
||||||
const LessonDetail = () => {
|
const LessonDetail = () => {
|
||||||
const { lessonId, courseId } = useParams()
|
const { lessonId, courseId } = useParams()
|
||||||
const canvRef = useRef(null)
|
|
||||||
const user = useAppSelector((s) => s.user)
|
const user = useAppSelector((s) => s.user)
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { colorMode } = useColorMode()
|
const { colorMode } = useColorMode()
|
||||||
@ -64,191 +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 {
|
const {
|
||||||
isFetching,
|
isFetching,
|
||||||
data: accessCode,
|
accessCode,
|
||||||
isSuccess,
|
isSuccess,
|
||||||
refetch,
|
handleManualAdd
|
||||||
} = api.useCreateAccessCodeQuery(
|
} = useAccessCode(lessonId, user)
|
||||||
{ lessonId },
|
|
||||||
{
|
|
||||||
skip: !isTeacher(user),
|
|
||||||
pollingInterval:
|
|
||||||
Number(getConfigValue('journal.polling-interval')) || 3000,
|
|
||||||
skipPollingIfUnfocused: true,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
const AllStudents = api.useCourseAllStudentsQuery(courseId)
|
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(
|
const userUrl = useMemo(
|
||||||
() => `${location.origin}/journal/u/${lessonId}/${accessCode?.body?._id}`,
|
() => `${location.origin}/journal/u/${lessonId}/${accessCode?.body?._id}`,
|
||||||
[accessCode, lessonId],
|
[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?.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]);
|
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Container maxW="2280px">
|
<Container maxW="2280px">
|
||||||
@ -257,7 +89,6 @@ 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
|
||||||
@ -270,56 +101,19 @@ const LessonDetail = () => {
|
|||||||
position="sticky"
|
position="sticky"
|
||||||
top="20px"
|
top="20px"
|
||||||
zIndex="2"
|
zIndex="2"
|
||||||
><Box pb={3}>
|
>
|
||||||
{formatDate(accessCode?.body?.lesson?.date, t('journal.pl.lesson.dateFormat'))}{' '}
|
<AttendanceStats
|
||||||
{t('journal.pl.common.marked')} -
|
lessonDate={accessCode?.body?.lesson?.date}
|
||||||
{AllStudents.isSuccess && (
|
studentsPresent={accessCode?.body?.lesson?.students?.length || 0}
|
||||||
<Box
|
totalStudents={AllStudents?.data?.body?.length || 0}
|
||||||
as="span"
|
isLoading={!AllStudents.isSuccess}
|
||||||
px={2}
|
isPulsing={isPulsing}
|
||||||
py={1}
|
/>
|
||||||
ml={2}
|
<QRCodeDisplay
|
||||||
borderRadius="md"
|
url={userUrl}
|
||||||
fontWeight="bold"
|
isFetching={isFetching}
|
||||||
bg={getAttendanceColor(
|
isSuccess={isSuccess}
|
||||||
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>
|
|
||||||
</Box>
|
</Box>
|
||||||
<Box
|
<Box
|
||||||
flex={1}
|
flex={1}
|
||||||
@ -328,174 +122,13 @@ const LessonDetail = () => {
|
|||||||
bg={colorMode === "light" ? "gray.50" : "gray.700"}
|
bg={colorMode === "light" ? "gray.50" : "gray.700"}
|
||||||
boxShadow="md"
|
boxShadow="md"
|
||||||
>
|
>
|
||||||
<StudentList>
|
{isTeacher(user) && (
|
||||||
{isTeacher(user) && (
|
<StudentList
|
||||||
<AnimatePresence initial={false}>
|
students={studentsArr}
|
||||||
{studentsArr.map((student) => (
|
onAddUser={handleManualAdd}
|
||||||
<motion.li
|
studentReactions={studentReactions}
|
||||||
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 })}
|
|
||||||
reaction={accessCode?.body?.lesson?.studentReactions?.find(r => r.sub === student.sub)}
|
|
||||||
/>
|
|
||||||
</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>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Container>
|
</Container>
|
||||||
|
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' } }
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user