Emojy reactions

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

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