Emojy reactions
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user