Emojy reactions

This commit is contained in:
Primakov Alexandr Alexandrovich 2025-03-25 19:12:47 +03:00
parent c02cf6dfc9
commit b2121cc133
12 changed files with 983 additions and 14 deletions

View File

@ -89,6 +89,16 @@
"journal.pl.lesson.aiGenerationError": "Error generating AI suggestions",
"journal.pl.lesson.tryAgainLater": "An error occurred while generating lesson suggestions. Please try again later.",
"journal.pl.lesson.retryGeneration": "Retry Generation",
"journal.pl.lesson.reactions": "Reactions to the lesson:",
"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.reactions.thumbs_up": "Thumbs up",
"journal.pl.reactions.heart": "Heart",
"journal.pl.reactions.laugh": "Laugh",
"journal.pl.reactions.wow": "Wow",
"journal.pl.reactions.clap": "Clap",
"journal.pl.exam.title": "Exam",
"journal.pl.exam.startExam": "Start exam",

View File

@ -86,6 +86,16 @@
"journal.pl.lesson.aiGenerationError": "Ошибка генерации рекомендаций ИИ",
"journal.pl.lesson.tryAgainLater": "Произошла ошибка при генерации рекомендаций для занятий. Пожалуйста, попробуйте позже.",
"journal.pl.lesson.retryGeneration": "Повторить генерацию",
"journal.pl.lesson.reactions": "Реакции на занятие:",
"journal.pl.lesson.noStudents": "Пока нет студентов",
"journal.pl.lesson.waitForStudents": "Студенты, посетившие занятие, появятся здесь",
"journal.pl.lesson.notMarked": "Не отмечен",
"journal.pl.reactions.thumbs_up": "Палец вверх",
"journal.pl.reactions.heart": "Сердце",
"journal.pl.reactions.laugh": "Смех",
"journal.pl.reactions.wow": "Вау",
"journal.pl.reactions.clap": "Аплодисменты",
"journal.pl.exam.title": "Экзамен",
"journal.pl.exam.startExam": "Начать экзамен",

View File

@ -122,6 +122,15 @@ export const api = createApi({
method: 'GET',
}),
}),
sendReaction: builder.mutation<void, { lessonId: string; reaction: string }>({
query: ({ lessonId, reaction }) => ({
url: `/lesson/reaction/${lessonId}`,
method: 'POST',
body: { reaction },
}),
}),
getCourseById: builder.query<PopulatedCourse, string>({
query: (courseId) => `/course/${courseId}`,
transformResponse: (response: BaseResponse<PopulatedCourse>) => response.body,

View File

@ -49,10 +49,17 @@ export type BaseResponse<Data> = {
body: Data;
};
export interface Reaction {
_id: string;
sub: string;
reaction: string;
}
export interface Lesson {
id: string;
_id: string;
name: string;
reactions: Reaction[];
students: User[];
teachers: Teacher[];
date: string;

View File

@ -53,7 +53,7 @@ export const NameOverlay = styled.div`
`
// Стили без интерполяций компонентов
export const Wrapper = styled.div<{ warn?: boolean; width?: string | number }>`
export const Wrapper = styled.div<{ warn?: boolean; width?: string | number; position?: string }>`
list-style: none;
position: relative;
border-radius: 12px;
@ -98,6 +98,13 @@ export const Wrapper = styled.div<{ warn?: boolean; width?: string | number }>`
`
: ''}
${({ position }) =>
position
? css`
position: ${position};
`
: ''}
${(props) =>
props.warn
? css`

View File

@ -1,13 +1,24 @@
import React from 'react'
import { sha256 } from 'js-sha256'
import { useState } from 'react'
import { Box, useColorMode } from '@chakra-ui/react'
import { useState, useEffect, useRef } from 'react'
import { Box, useColorMode, Text } from '@chakra-ui/react'
import { CheckCircleIcon, AddIcon } from '@chakra-ui/icons'
import { motion, AnimatePresence } from 'framer-motion'
import { useTranslation } from 'react-i18next'
import { User } from '../../__data__/model'
import { User, Reaction } from '../../__data__/model'
import { AddMissedButton, Avatar, Wrapper, NameOverlay } from './style'
// Map of reaction types to emojis
const REACTION_EMOJIS = {
thumbs_up: '👍',
heart: '❤️',
laugh: '😂',
wow: '😮',
clap: '👏'
}
export function getGravatarURL(email, user) {
if (!email) return void 0
const address = String(email).trim().toLowerCase()
@ -22,7 +33,8 @@ export const UserCard = ({
onAddUser = undefined,
wrapperAS = 'div',
width,
recentlyPresent = false
recentlyPresent = false,
reactions = []
}: {
student: User
present: boolean
@ -30,9 +42,50 @@ export const UserCard = ({
onAddUser?: (user: User) => void
wrapperAS?: React.ElementType<any, keyof React.JSX.IntrinsicElements>;
recentlyPresent?: boolean
reactions?: Reaction[]
}) => {
const { colorMode } = useColorMode();
const { t } = useTranslation();
const [imageError, setImageError] = useState(false);
const [visibleReactions, setVisibleReactions] = useState<Reaction[]>([]);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
// Filter reactions to only show this student's reactions
useEffect(() => {
const studentReactions = reactions.filter(r => r.sub === student.sub);
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([]);
timeoutRef.current = null;
}, 3000);
}
}
// Clean up on unmount
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, [reactions, student.sub, visibleReactions]);
return (
<Wrapper
@ -40,6 +93,7 @@ export const UserCard = ({
as={wrapperAS}
width={width}
className={!present ? 'warn' : recentlyPresent ? 'recent' : ''}
position="relative"
>
<Avatar
src={imageError ? getGravatarURL(student.email, null) : (student.picture || getGravatarURL(student.email, null))}
@ -58,10 +112,52 @@ export const UserCard = ({
)}
</NameOverlay>
{onAddUser && !present && (
<AddMissedButton onClick={() => onAddUser(student)} aria-label="Отметить присутствие">
<AddMissedButton onClick={() => onAddUser(student)} aria-label={t('journal.pl.common.add')}>
<AddIcon boxSize={3} />
</AddMissedButton>
)}
{/* Student reactions animation */}
<AnimatePresence>
{visibleReactions.map((reaction, index) => (
<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 }}
exit={{ opacity: 0, scale: 0.5, y: -20 }}
transition={{
duration: 0.5,
delay: index * 0.1
}}
style={{
position: 'absolute',
top: '10px', // Position at the top
right: '10px', // Position at the right
zIndex: 10,
pointerEvents: 'none',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
background: colorMode === 'light' ? 'rgba(255, 255, 255, 0.8)' : 'rgba(0, 0, 0, 0.6)',
borderRadius: '50%',
padding: '2px',
boxShadow: '0 2px 5px rgba(0,0,0,0.2)'
}}
title={t(`journal.pl.reactions.${reaction.reaction}`)}
>
<Text
fontSize="3xl" // Increased size
sx={{
filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.3))',
transform: 'scale(1.2)', // Additional scaling
display: 'flex'
}}
>
{REACTION_EMOJIS[reaction.reaction] || reaction.reaction}
</Text>
</motion.div>
))}
</AnimatePresence>
</Wrapper>
)
}

View File

@ -16,7 +16,7 @@ import {
import { useTranslation } from 'react-i18next'
import { api } from '../__data__/api/api'
import { User } from '../__data__/model'
import { User, Reaction } from '../__data__/model'
import { UserCard } from '../components/user-card'
import { formatDate } from '../utils/dayjs-config'
import { useSetBreadcrumbs } from '../components'
@ -71,6 +71,10 @@ const LessonDetail = () => {
const [isPulsing, setIsPulsing] = useState(false)
// Отслеживаем предыдущее количество студентов
const prevStudentCountRef = useRef(0)
// Отслеживаем предыдущие реакции для определения новых
const prevReactionsRef = useRef<Record<string, Reaction[]>>({})
// Храним актуальные реакции студентов
const [studentReactions, setStudentReactions] = useState<Record<string, Reaction[]>>({})
const {
isFetching,
@ -121,6 +125,44 @@ const LessonDetail = () => {
}
}, [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()
@ -348,6 +390,7 @@ const LessonDetail = () => {
present={student.present}
recentlyPresent={student.recentlyPresent}
onAddUser={(user: User) => manualAdd({ lessonId, user })}
reactions={studentReactions[student.sub] || []}
/>
</Box>

View File

@ -17,17 +17,31 @@ import {
Badge,
Flex,
useColorMode,
IconButton,
Tooltip,
HStack,
} from '@chakra-ui/react'
import { UserCard } from '../components/user-card'
import { StudentListView } from './style'
import { useSetBreadcrumbs } from '../components'
// Reaction emojis with their string values
const REACTIONS = [
{ emoji: '👍', value: 'thumbs_up' },
{ emoji: '❤️', value: 'heart' },
{ emoji: '😂', value: 'laugh' },
{ emoji: '😮', value: 'wow' },
{ emoji: '👏', value: 'clap' },
]
const UserPage = () => {
const { lessonId, accessId } = useParams()
const { t } = useTranslation()
const { colorMode } = useColorMode()
const acc = api.useGetAccessQuery({ accessCode: accessId })
const [animatedStudents, setAnimatedStudents] = useState([])
const [sendReaction] = api.useSendReactionMutation()
const [activeReaction, setActiveReaction] = useState(null)
const ls = api.useLessonByIdQuery(lessonId, {
pollingInterval: 1000,
@ -85,6 +99,19 @@ const UserPage = () => {
}
}, [animatedStudents])
// Обработчик отправки реакции
const handleReaction = (reaction) => {
if (lessonId) {
sendReaction({ lessonId, reaction })
setActiveReaction(reaction)
// Сбрасываем активную реакцию через 1 секунду
setTimeout(() => {
setActiveReaction(null)
}, 1000)
}
}
if (acc.isLoading) {
return (
<Container maxW="container.xl">
@ -168,6 +195,49 @@ const UserPage = () => {
</Box>
</motion.div>
{/* Реакции на занятие */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.3 }}
>
<Box
mb={6}
p={5}
borderRadius="xl"
bg={colorMode === "light" ? "gray.50" : "gray.700"}
boxShadow="md"
>
<Text mb={3} fontWeight="medium">{t('journal.pl.lesson.reactions')}</Text>
<HStack spacing={3} justify="center">
{REACTIONS.map((reaction) => (
<Tooltip key={reaction.value} label={t(`journal.pl.reactions.${reaction.value}`)} placement="top">
<IconButton
aria-label={t(`journal.pl.reactions.${reaction.value}`)}
icon={<Text fontSize="24px">{reaction.emoji}</Text>}
size="lg"
variant={activeReaction === reaction.value ? "solid" : "outline"}
colorScheme={activeReaction === reaction.value ? "blue" : "gray"}
onClick={() => handleReaction(reaction.value)}
transition="all 0.2s"
_hover={{ transform: "scale(1.1)" }}
sx={{
animation: activeReaction === reaction.value
? "pulse 0.5s ease-in-out" : "none",
"@keyframes pulse": {
"0%": { transform: "scale(1)" },
"50%": { transform: "scale(1.2)" },
"100%": { transform: "scale(1)" }
}
}}
/>
</Tooltip>
))}
</HStack>
</Box>
</motion.div>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
@ -207,6 +277,7 @@ const UserPage = () => {
student={student}
present={true}
recentlyPresent={student.isNew}
reactions={ls.data?.body?.reactions?.filter(r => r.sub === student.sub) || []}
/>
</motion.li>
))}

View File

@ -1,6 +1,7 @@
const router = require('express').Router()
const fs = require('node:fs')
const path = require('node:path')
const mockGenerator = require('./mock-generator')
const timer =
(time = 1000) =>
@ -56,19 +57,21 @@ router.post('/lesson', (req, res) => {
})
router.post('/lesson/access-code', (req, res) => {
const answer = fs.readFileSync(
path.resolve(__dirname, '../mocks/lessons/access-code/create/success.json'),
)
// res.send(require('../mocks/lessons/access-code/create/success.json'))
res.send(answer)
// Generate random students and reactions dynamically
const dynamicData = mockGenerator.generateDynamicAccessCodeResponse();
res.send(dynamicData);
})
router.get('/lesson/access-code/:accessCode', (req, res) => {
res.status(400).send(require('../mocks/lessons/access-code/get/error.json'))
// Generate dynamic data for the access code lookup
const dynamicData = mockGenerator.generateDynamicAccessLookupResponse(req.params.accessCode);
res.send(dynamicData);
})
router.get('/lesson/:lessonId', (req, res) => {
res.send(require('../mocks/lessons/byid/success.json'))
// Generate dynamic lesson data using the same helpers
const dynamicData = mockGenerator.generateDynamicLessonResponse(req.params.lessonId);
res.send(dynamicData);
})
router.delete('/lesson/:lessonId', (req, res) => {
@ -79,4 +82,291 @@ router.put('/lesson', (req, res) => {
res.send({ success: true, body: req.body })
})
router.post('/lesson/reaction/:lessonId', (req, res) => {
// Simulate processing a new reaction
const { reaction } = req.body;
const lessonId = req.params.lessonId;
// Log the reaction for debugging
console.log(`Received reaction "${reaction}" for lesson ${lessonId}`);
// Return success response
res.send({
success: true,
body: {
_id: `r-${Date.now()}-${Math.floor(Math.random() * 1000)}`,
reaction,
lessonId,
created: new Date().toISOString()
}
});
});
module.exports = router
// Database of potential students
const potentialStudents = [
{
"sub": "fcde3f22-d9ba-412a-a572-c59e515a290f",
"email_verified": true,
"name": "Мария Капитанова",
"preferred_username": "maryaKapitan@gmail.com",
"given_name": "Мария",
"family_name": "Капитанова",
"email": "maryaKapitan@gmail.com",
"picture": "https://lh3.googleusercontent.com/a/ACg8ocJgIjjOFD2YUSyRF5kH4jaysE6X5p-kq0Cg0CFncfMi=s96-c"
},
{
"sub": "8555885b-715c-4dee-a7c5-9563a6a05211",
"email_verified": true,
"name": "Евгения Жужова",
"preferred_username": "zhuzhova@gmail.com",
"given_name": "Евгения",
"family_name": "Жужова",
"email": "zhuzhova@gmail.com",
"picture": "https://lh3.googleusercontent.com/a/ACg8ocJUtJBAVBm642AxoGpMDDMV8CPu3MEoLjU3hmO7oisG=s96-c"
},
{
"sub": "a723b8c2-f1d7-4620-9c35-1d48c821afb7",
"email_verified": true,
"name": "Иван Петров",
"preferred_username": "ivan.petrov@gmail.com",
"given_name": "Иван",
"family_name": "Петров",
"email": "ivan.petrov@gmail.com",
"picture": "https://lh3.googleusercontent.com/a/ACg8ocJKHmMLFXY1s0Lkj_KKf9ZEsHl-rW6FnDs4vPHUl2aF=s96-c"
},
{
"sub": "e4f9d328-7b2e-49c1-b5e8-12f78c54a63d",
"email_verified": true,
"name": "Алексей Смирнов",
"preferred_username": "alexey.smirnov@gmail.com",
"given_name": "Алексей",
"family_name": "Смирнов",
"email": "alexey.smirnov@gmail.com",
"picture": "https://lh3.googleusercontent.com/a/ACg8ocK9Nfj_jT4DLjG5hVQWS2bz8_QTZ3cHVJ6K8mD8aqWr=s96-c"
},
{
"sub": "b9d7e1f5-6a3c-47d0-9bce-3c54e28a0ef2",
"email_verified": true,
"name": "Ольга Иванова",
"preferred_username": "olga.ivanova@gmail.com",
"given_name": "Ольга",
"family_name": "Иванова",
"email": "olga.ivanova@gmail.com",
"picture": "https://lh3.googleusercontent.com/a/ACg8ocI48DY7C2ZXbMvHrEjKmY6w9JdF5PLKwEDgTR9x1jY2=s96-c"
},
{
"sub": "c5e8d4f3-2b1a-4c9d-8e7f-6a5b4c3d2e1f",
"email_verified": true,
"name": "Дмитрий Кузнецов",
"preferred_username": "dmitry.kuznetsov@gmail.com",
"given_name": "Дмитрий",
"family_name": "Кузнецов",
"email": "dmitry.kuznetsov@gmail.com",
"picture": "https://lh3.googleusercontent.com/a/ACg8ocLqZD7KjXy3B1P2VsRn6Z9tY8XMhCJ6F5gK7sD1qB3t=s96-c"
},
{
"sub": "d6f9e8d7-3c2b-4a1d-9e8f-7a6b5c4d3e2f",
"email_verified": true,
"name": "Анна Соколова",
"preferred_username": "anna.sokolova@gmail.com",
"given_name": "Анна",
"family_name": "Соколова",
"email": "anna.sokolova@gmail.com",
"picture": "https://lh3.googleusercontent.com/a/ACg8ocK3dN5mYwLjE1qFvX9pZ8rY1hJ5L2mN3oP6gR7tUb4s=s96-c"
},
{
"sub": "e7f8g9h0-4d3c-2b1a-0f9e-8d7c6b5a4e3d",
"email_verified": true,
"name": "Сергей Новиков",
"preferred_username": "sergey.novikov@gmail.com",
"given_name": "Сергей",
"family_name": "Новиков",
"email": "sergey.novikov@gmail.com",
"picture": "https://lh3.googleusercontent.com/a/ACg8ocI7P2dF3vQ5wR9jH6tN8bZ1cM4kD6yL2jN5oR8tYb5r=s96-c"
},
{
"sub": "f8g9h0i1-5e4d-3c2b-1a0f-9e8d7c6b5a4e",
"email_verified": true,
"name": "Екатерина Морозова",
"preferred_username": "ekaterina.morozova@gmail.com",
"given_name": "Екатерина",
"family_name": "Морозова",
"email": "ekaterina.morozova@gmail.com",
"picture": "https://lh3.googleusercontent.com/a/ACg8ocJ6N5oR7sD8tUb4rY1hJ5L2mN3oP6gR7tUb4s9pZ8=s96-c"
},
{
"sub": "g9h0i1j2-6f5e-4d3c-2b1a-0f9e8d7c6b5a",
"email_verified": true,
"name": "Андрей Волков",
"preferred_username": "andrey.volkov@gmail.com",
"given_name": "Андрей",
"family_name": "Волков",
"email": "andrey.volkov@gmail.com",
"picture": "https://lh3.googleusercontent.com/a/ACg8ocK4e3d2c1b0a9f8e7d6c5b4a3e2d1c0b9a8f7e6d5=s96-c"
}
];
// Available reaction types
const reactionTypes = ['thumbs_up', 'heart', 'laugh', 'wow', 'clap'];
// Function to generate a random integer between min and max (inclusive)
function getRandomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
// Function to generate a random subset of students
function getRandomStudents() {
const totalStudents = potentialStudents.length;
const count = getRandomInt(1, Math.min(8, totalStudents));
// Shuffle array and take a subset
const shuffled = [...potentialStudents].sort(() => 0.5 - Math.random());
return shuffled.slice(0, count);
}
// Function to generate a random reaction
function generateReaction(studentSub, index) {
const reactionType = reactionTypes[getRandomInt(0, reactionTypes.length - 1)];
return {
"_id": `r-${Date.now()}-${index}`,
"sub": studentSub,
"reaction": reactionType,
"created": new Date().toISOString()
};
}
// Function to generate random reactions for each student
function generateReactions(students) {
const reactions = [];
let reactionIndex = 0;
students.forEach(student => {
// Small chance (20%) of a "reaction burst" - multiple reactions in rapid succession
const hasBurst = Math.random() < 0.2;
if (hasBurst) {
// Generate a burst of 2-5 rapid reactions
const burstCount = getRandomInt(2, 5);
const now = Date.now();
for (let i = 0; i < burstCount; i++) {
// Reactions spaced 0.5-2 seconds apart
const timeOffset = i * getRandomInt(500, 2000);
const reactionTime = new Date(now - timeOffset);
reactions.push({
"_id": `r-burst-${now}-${i}-${reactionIndex++}`,
"sub": student.sub,
"reaction": reactionTypes[getRandomInt(0, reactionTypes.length - 1)],
"created": reactionTime.toISOString()
});
}
} else {
// Each student may have 0-3 random reactions
const reactionCount = getRandomInt(0, 3);
for (let i = 0; i < reactionCount; i++) {
// Space out regular reactions by 5-30 seconds
const timeOffset = getRandomInt(5000, 30000);
const reactionTime = new Date(Date.now() - timeOffset);
reactions.push({
"_id": `r-${Date.now()}-${reactionIndex++}`,
"sub": student.sub,
"reaction": reactionTypes[getRandomInt(0, reactionTypes.length - 1)],
"created": reactionTime.toISOString()
});
}
}
});
// Sort reactions by creation time (newest first)
return reactions.sort((a, b) => new Date(b.created) - new Date(a.created));
}
// Function to generate the entire dynamic response
function generateDynamicAccessCodeResponse() {
// Base template from the static file
const baseTemplate = {
"success": true,
"body": {
"expires": new Date(Date.now() + 60 * 60 * 1000).toISOString(), // 1 hour from now
"lesson": {
"_id": "65df996c584b172772d69706",
"name": "ВВОДНАЯ ПО JS.ПРИМЕНЕНИЕ И СПОСОБЫ ПОДКЛЮЧЕНИЯ НА СТРАНИЦЕ. LET, CONST. БАЗОВЫЕ ТИПЫ ДАННЫХ, ПРИВЕДЕНИЕ ТИПОВ. ПЕРЕМЕННЫЕ, ОБЛАСТЬ ВИДИМОСТИ ПЕРЕМЕННЫХ",
"date": new Date().toISOString(),
"created": new Date().toISOString(),
"__v": 0
},
"_id": `access-${Date.now()}`,
"created": new Date().toISOString(),
"__v": 0
}
};
// Generate random students
const students = getRandomStudents();
baseTemplate.body.lesson.students = students;
// Generate random reactions for those students
baseTemplate.body.lesson.reactions = generateReactions(students);
return baseTemplate;
}
// Function to generate a dynamic lesson response
function generateDynamicLessonResponse(lessonId) {
// Base template for lesson response
const baseTemplate = {
"success": true,
"body": {
"_id": lessonId || "65df996c584b172772d69706",
"name": "ВВОДНАЯ ПО JS.ПРИМЕНЕНИЕ И СПОСОБЫ ПОДКЛЮЧЕНИЯ НА СТРАНИЦЕ. LET, CONST. БАЗОВЫЕ ТИПЫ ДАННЫХ, ПРИВЕДЕНИЕ ТИПОВ. ПЕРЕМЕННЫЕ, ОБЛАСТЬ ВИДИМОСТИ ПЕРЕМЕННЫХ",
"date": new Date().toISOString(),
"created": new Date().toISOString(),
"__v": 0
}
};
// Generate random students
const students = getRandomStudents();
baseTemplate.body.students = students;
// Generate random reactions for those students
baseTemplate.body.reactions = generateReactions(students);
return baseTemplate;
}
// Function to generate a dynamic access code lookup response
function generateDynamicAccessLookupResponse(accessCode) {
// Generate a lesson with students and reactions
const lessonData = generateDynamicLessonResponse();
// Create a mock user
const mockUser = {
sub: `user-${Date.now()}`,
email_verified: true,
name: "Текущий Пользователь",
preferred_username: "current.user@example.com",
email: "current.user@example.com"
};
// Combine into the expected format
return {
"success": true,
"body": {
"user": mockUser,
"accessCode": {
"expires": new Date(Date.now() + 60 * 60 * 1000).toISOString(),
"lesson": lessonData.body,
"_id": accessCode || `access-${Date.now()}`,
"created": new Date().toISOString(),
"__v": 0
}
}
};
}

264
stubs/api/mock-generator.js Normal file
View File

@ -0,0 +1,264 @@
// Database of potential students
const potentialStudents = [
{
"sub": "fcde3f22-d9ba-412a-a572-c59e515a290f",
"email_verified": true,
"name": "Мария Капитанова",
"preferred_username": "maryaKapitan@gmail.com",
"given_name": "Мария",
"family_name": "Капитанова",
"email": "maryaKapitan@gmail.com",
"picture": "https://lh3.googleusercontent.com/a/ACg8ocJgIjjOFD2YUSyRF5kH4jaysE6X5p-kq0Cg0CFncfMi=s96-c"
},
{
"sub": "8555885b-715c-4dee-a7c5-9563a6a05211",
"email_verified": true,
"name": "Евгения Жужова",
"preferred_username": "zhuzhova@gmail.com",
"given_name": "Евгения",
"family_name": "Жужова",
"email": "zhuzhova@gmail.com",
"picture": "https://lh3.googleusercontent.com/a/ACg8ocJUtJBAVBm642AxoGpMDDMV8CPu3MEoLjU3hmO7oisG=s96-c"
},
{
"sub": "a723b8c2-f1d7-4620-9c35-1d48c821afb7",
"email_verified": true,
"name": "Иван Петров",
"preferred_username": "ivan.petrov@gmail.com",
"given_name": "Иван",
"family_name": "Петров",
"email": "ivan.petrov@gmail.com",
"picture": "https://lh3.googleusercontent.com/a/ACg8ocJKHmMLFXY1s0Lkj_KKf9ZEsHl-rW6FnDs4vPHUl2aF=s96-c"
},
{
"sub": "e4f9d328-7b2e-49c1-b5e8-12f78c54a63d",
"email_verified": true,
"name": "Алексей Смирнов",
"preferred_username": "alexey.smirnov@gmail.com",
"given_name": "Алексей",
"family_name": "Смирнов",
"email": "alexey.smirnov@gmail.com",
"picture": "https://lh3.googleusercontent.com/a/ACg8ocK9Nfj_jT4DLjG5hVQWS2bz8_QTZ3cHVJ6K8mD8aqWr=s96-c"
},
{
"sub": "b9d7e1f5-6a3c-47d0-9bce-3c54e28a0ef2",
"email_verified": true,
"name": "Ольга Иванова",
"preferred_username": "olga.ivanova@gmail.com",
"given_name": "Ольга",
"family_name": "Иванова",
"email": "olga.ivanova@gmail.com",
"picture": "https://lh3.googleusercontent.com/a/ACg8ocI48DY7C2ZXbMvHrEjKmY6w9JdF5PLKwEDgTR9x1jY2=s96-c"
},
{
"sub": "c5e8d4f3-2b1a-4c9d-8e7f-6a5b4c3d2e1f",
"email_verified": true,
"name": "Дмитрий Кузнецов",
"preferred_username": "dmitry.kuznetsov@gmail.com",
"given_name": "Дмитрий",
"family_name": "Кузнецов",
"email": "dmitry.kuznetsov@gmail.com",
"picture": "https://lh3.googleusercontent.com/a/ACg8ocLqZD7KjXy3B1P2VsRn6Z9tY8XMhCJ6F5gK7sD1qB3t=s96-c"
},
{
"sub": "d6f9e8d7-3c2b-4a1d-9e8f-7a6b5c4d3e2f",
"email_verified": true,
"name": "Анна Соколова",
"preferred_username": "anna.sokolova@gmail.com",
"given_name": "Анна",
"family_name": "Соколова",
"email": "anna.sokolova@gmail.com",
"picture": "https://lh3.googleusercontent.com/a/ACg8ocK3dN5mYwLjE1qFvX9pZ8rY1hJ5L2mN3oP6gR7tUb4s=s96-c"
},
{
"sub": "e7f8g9h0-4d3c-2b1a-0f9e-8d7c6b5a4e3d",
"email_verified": true,
"name": "Сергей Новиков",
"preferred_username": "sergey.novikov@gmail.com",
"given_name": "Сергей",
"family_name": "Новиков",
"email": "sergey.novikov@gmail.com",
"picture": "https://lh3.googleusercontent.com/a/ACg8ocI7P2dF3vQ5wR9jH6tN8bZ1cM4kD6yL2jN5oR8tYb5r=s96-c"
},
{
"sub": "f8g9h0i1-5e4d-3c2b-1a0f-9e8d7c6b5a4e",
"email_verified": true,
"name": "Екатерина Морозова",
"preferred_username": "ekaterina.morozova@gmail.com",
"given_name": "Екатерина",
"family_name": "Морозова",
"email": "ekaterina.morozova@gmail.com",
"picture": "https://lh3.googleusercontent.com/a/ACg8ocJ6N5oR7sD8tUb4rY1hJ5L2mN3oP6gR7tUb4s9pZ8=s96-c"
},
{
"sub": "g9h0i1j2-6f5e-4d3c-2b1a-0f9e8d7c6b5a",
"email_verified": true,
"name": "Андрей Волков",
"preferred_username": "andrey.volkov@gmail.com",
"given_name": "Андрей",
"family_name": "Волков",
"email": "andrey.volkov@gmail.com",
"picture": "https://lh3.googleusercontent.com/a/ACg8ocK4e3d2c1b0a9f8e7d6c5b4a3e2d1c0b9a8f7e6d5=s96-c"
}
];
// Available reaction types
const reactionTypes = ['thumbs_up', 'heart', 'laugh', 'wow', 'clap'];
// Function to generate a random integer between min and max (inclusive)
function getRandomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
// Function to generate a random subset of students
function getRandomStudents() {
const totalStudents = potentialStudents.length;
const count = getRandomInt(1, Math.min(8, totalStudents));
// Shuffle array and take a subset
const shuffled = [...potentialStudents].sort(() => 0.5 - Math.random());
return shuffled.slice(0, count);
}
// Function to generate random reactions for each student
function generateReactions(students) {
const reactions = [];
let reactionIndex = 0;
students.forEach(student => {
// Small chance (20%) of a "reaction burst" - multiple reactions in rapid succession
const hasBurst = Math.random() < 0.2;
if (hasBurst) {
// Generate a burst of 2-5 rapid reactions
const burstCount = getRandomInt(2, 5);
const now = Date.now();
for (let i = 0; i < burstCount; i++) {
// Reactions spaced 0.5-2 seconds apart
const timeOffset = i * getRandomInt(500, 2000);
const reactionTime = new Date(now - timeOffset);
reactions.push({
"_id": `r-burst-${now}-${i}-${reactionIndex++}`,
"sub": student.sub,
"reaction": reactionTypes[getRandomInt(0, reactionTypes.length - 1)],
"created": reactionTime.toISOString()
});
}
} else {
// Each student may have 0-3 random reactions
const reactionCount = getRandomInt(0, 3);
for (let i = 0; i < reactionCount; i++) {
// Space out regular reactions by 5-30 seconds
const timeOffset = getRandomInt(5000, 30000);
const reactionTime = new Date(Date.now() - timeOffset);
reactions.push({
"_id": `r-${Date.now()}-${reactionIndex++}`,
"sub": student.sub,
"reaction": reactionTypes[getRandomInt(0, reactionTypes.length - 1)],
"created": reactionTime.toISOString()
});
}
}
});
// Sort reactions by creation time (newest first)
return reactions.sort((a, b) => new Date(b.created) - new Date(a.created));
}
// Function to generate the entire dynamic response
function generateDynamicAccessCodeResponse() {
// Base template from the static file
const baseTemplate = {
"success": true,
"body": {
"expires": new Date(Date.now() + 60 * 60 * 1000).toISOString(), // 1 hour from now
"lesson": {
"_id": "65df996c584b172772d69706",
"name": "ВВОДНАЯ ПО JS.ПРИМЕНЕНИЕ И СПОСОБЫ ПОДКЛЮЧЕНИЯ НА СТРАНИЦЕ. LET, CONST. БАЗОВЫЕ ТИПЫ ДАННЫХ, ПРИВЕДЕНИЕ ТИПОВ. ПЕРЕМЕННЫЕ, ОБЛАСТЬ ВИДИМОСТИ ПЕРЕМЕННЫХ",
"date": new Date().toISOString(),
"created": new Date().toISOString(),
"__v": 0
},
"_id": `access-${Date.now()}`,
"created": new Date().toISOString(),
"__v": 0
}
};
// Generate random students
const students = getRandomStudents();
baseTemplate.body.lesson.students = students;
// Generate random reactions for those students
baseTemplate.body.lesson.reactions = generateReactions(students);
return baseTemplate;
}
// Function to generate a dynamic lesson response
function generateDynamicLessonResponse(lessonId) {
// Base template for lesson response
const baseTemplate = {
"success": true,
"body": {
"_id": lessonId || "65df996c584b172772d69706",
"name": "ВВОДНАЯ ПО JS.ПРИМЕНЕНИЕ И СПОСОБЫ ПОДКЛЮЧЕНИЯ НА СТРАНИЦЕ. LET, CONST. БАЗОВЫЕ ТИПЫ ДАННЫХ, ПРИВЕДЕНИЕ ТИПОВ. ПЕРЕМЕННЫЕ, ОБЛАСТЬ ВИДИМОСТИ ПЕРЕМЕННЫХ",
"date": new Date().toISOString(),
"created": new Date().toISOString(),
"__v": 0
}
};
// Generate random students
const students = getRandomStudents();
baseTemplate.body.students = students;
// Generate random reactions for those students
baseTemplate.body.reactions = generateReactions(students);
return baseTemplate;
}
// Function to generate a dynamic access code lookup response
function generateDynamicAccessLookupResponse(accessCode) {
// Generate a lesson with students and reactions
const lessonData = generateDynamicLessonResponse();
// Create a mock user
const mockUser = {
sub: `user-${Date.now()}`,
email_verified: true,
name: "Текущий Пользователь",
preferred_username: "current.user@example.com",
email: "current.user@example.com"
};
// Combine into the expected format
return {
"success": true,
"body": {
"user": mockUser,
"accessCode": {
"expires": new Date(Date.now() + 60 * 60 * 1000).toISOString(),
"lesson": lessonData.body,
"_id": accessCode || `access-${Date.now()}`,
"created": new Date().toISOString(),
"__v": 0
}
}
};
}
// Export all the necessary functions
module.exports = {
getRandomStudents,
generateReactions,
generateDynamicAccessCodeResponse,
generateDynamicLessonResponse,
generateDynamicAccessLookupResponse,
reactionTypes
};

View File

@ -27,6 +27,68 @@
"picture": "https://lh3.googleusercontent.com/a/ACg8ocJUtJBAVBm642AxoGpMDDMV8CPu3MEoLjU3hmO7oisG=s96-c"
}
],
"reactions": [
{
"_id": "r1d73f22-c9ba-422a-b572-c59e515a2901",
"sub": "fcde3f22-d9ba-412a-a572-c59e515a290f",
"reaction": "thumbs_up"
},
{
"_id": "r2d73f22-c9ba-422a-b572-c59e515a2902",
"sub": "fcde3f22-d9ba-412a-a572-c59e515a290f",
"reaction": "heart"
},
{
"_id": "r3d73f22-c9ba-422a-b572-c59e515a2903",
"sub": "8555885b-715c-4dee-a7c5-9563a6a05211",
"reaction": "clap"
},
{
"_id": "r4d73f22-c9ba-422a-b572-c59e515a2904",
"sub": "8555885b-715c-4dee-a7c5-9563a6a05211",
"reaction": "laugh"
},
{
"_id": "r5d73f22-c9ba-422a-b572-c59e515a2905",
"sub": "fcde3f22-d9ba-412a-a572-c59e515a290f",
"reaction": "wow"
},
{
"_id": "r6d73f22-c9ba-422a-b572-c59e515a2906",
"sub": "fcde3f22-d9ba-412a-a572-c59e515a290f",
"reaction": "thumbs_up"
},
{
"_id": "r7d73f22-c9ba-422a-b572-c59e515a2907",
"sub": "8555885b-715c-4dee-a7c5-9563a6a05211",
"reaction": "heart"
},
{
"_id": "r8d73f22-c9ba-422a-b572-c59e515a2908",
"sub": "8555885b-715c-4dee-a7c5-9563a6a05211",
"reaction": "clap"
},
{
"_id": "r9d73f22-c9ba-422a-b572-c59e515a2909",
"sub": "fcde3f22-d9ba-412a-a572-c59e515a290f",
"reaction": "laugh"
},
{
"_id": "r10d73f22-c9ba-422a-b572-c59e515a2910",
"sub": "8555885b-715c-4dee-a7c5-9563a6a05211",
"reaction": "wow"
},
{
"_id": "r11d73f22-c9ba-422a-b572-c59e515a2911",
"sub": "fcde3f22-d9ba-412a-a572-c59e515a290f",
"reaction": "laugh"
},
{
"_id": "r12d73f22-c9ba-422a-b572-c59e515a2912",
"sub": "8555885b-715c-4dee-a7c5-9563a6a05211",
"reaction": "heart"
}
],
"date": "2024-02-28T20:37:00.057Z",
"created": "2024-02-28T20:37:00.057Z",
"__v": 0

View File

@ -0,0 +1,100 @@
{
"success": true,
"body": {
"expires": "2024-03-01T07:52:16.374Z",
"lesson": {
"_id": "65df996c584b172772d69706",
"name": "ВВОДНАЯ ПО JS.ПРИМЕНЕНИЕ И СПОСОБЫ ПОДКЛЮЧЕНИЯ НА СТРАНИЦЕ. LET, CONST. БАЗОВЫЕ ТИПЫ ДАННЫХ, ПРИВЕДЕНИЕ ТИПОВ. ПЕРЕМЕННЫЕ, ОБЛАСТЬ ВИДИМОСТИ ПЕРЕМЕННЫХ",
"students": [
{
"sub": "fcde3f22-d9ba-412a-a572-c59e515a290f",
"email_verified": true,
"name": "Мария Капитанова",
"preferred_username": "maryaKapitan@gmail.com",
"given_name": "Мария",
"family_name": "Капитанова",
"email": "maryaKapitan@gmail.com",
"picture": "https://lh3.googleusercontent.com/a/ACg8ocJgIjjOFD2YUSyRF5kH4jaysE6X5p-kq0Cg0CFncfMi=s96-c"
},
{
"sub": "8555885b-715c-4dee-a7c5-9563a6a05211",
"email_verified": true,
"name": "Евгения Жужова",
"preferred_username": "zhuzhova@gmail.com",
"given_name": "Евгения",
"family_name": "Жужова",
"email": "zhuzhova@gmail.com",
"picture": "https://lh3.googleusercontent.com/a/ACg8ocJUtJBAVBm642AxoGpMDDMV8CPu3MEoLjU3hmO7oisG=s96-c"
}
],
"reactions": [
{
"_id": "r1-rapid-001",
"sub": "fcde3f22-d9ba-412a-a572-c59e515a290f",
"reaction": "thumbs_up",
"created": "2024-03-08T10:00:00.000Z"
},
{
"_id": "r1-rapid-002",
"sub": "fcde3f22-d9ba-412a-a572-c59e515a290f",
"reaction": "heart",
"created": "2024-03-08T10:00:01.500Z"
},
{
"_id": "r1-rapid-003",
"sub": "fcde3f22-d9ba-412a-a572-c59e515a290f",
"reaction": "laugh",
"created": "2024-03-08T10:00:02.800Z"
},
{
"_id": "r1-rapid-004",
"sub": "fcde3f22-d9ba-412a-a572-c59e515a290f",
"reaction": "wow",
"created": "2024-03-08T10:00:04.200Z"
},
{
"_id": "r1-rapid-005",
"sub": "fcde3f22-d9ba-412a-a572-c59e515a290f",
"reaction": "clap",
"created": "2024-03-08T10:00:05.500Z"
},
{
"_id": "r2-rapid-001",
"sub": "8555885b-715c-4dee-a7c5-9563a6a05211",
"reaction": "thumbs_up",
"created": "2024-03-08T10:01:00.000Z"
},
{
"_id": "r2-rapid-002",
"sub": "8555885b-715c-4dee-a7c5-9563a6a05211",
"reaction": "heart",
"created": "2024-03-08T10:01:01.200Z"
},
{
"_id": "r2-rapid-003",
"sub": "8555885b-715c-4dee-a7c5-9563a6a05211",
"reaction": "wow",
"created": "2024-03-08T10:01:02.300Z"
},
{
"_id": "r2-rapid-004",
"sub": "8555885b-715c-4dee-a7c5-9563a6a05211",
"reaction": "laugh",
"created": "2024-03-08T10:01:04.100Z"
},
{
"_id": "r2-rapid-005",
"sub": "8555885b-715c-4dee-a7c5-9563a6a05211",
"reaction": "clap",
"created": "2024-03-08T10:01:05.300Z"
}
],
"date": "2024-02-28T20:37:00.057Z",
"created": "2024-02-28T20:37:00.057Z",
"__v": 0
},
"_id": "65e18926584b172772d69722",
"created": "2024-03-01T07:52:06.375Z",
"__v": 0
}
}