Обновлен компонент CourseCard: добавлены адаптивные размеры и улучшена компоновка для различных экранов. Реализована возможность сворачивания/разворачивания списка уроков. Удален компонент CourseDetails, его функциональность интегрирована в CourseCard. Обновлен компонент CoursesList для поддержки адаптивного дизайна и улучшения пользовательского интерфейса.

This commit is contained in:
Primakov Alexandr Alexandrovich 2025-03-23 12:51:34 +03:00
parent 142ee6c496
commit ef8f7356e9
4 changed files with 141 additions and 164 deletions

View File

@ -34,13 +34,16 @@ import {
TagLeftIcon,
Wrap,
WrapItem,
useBreakpointValue,
useMediaQuery,
Icon
} from '@chakra-ui/react'
import { useTranslation } from 'react-i18next'
import { FaExpand, FaCompress } from 'react-icons/fa'
import { api } from '../../__data__/api/api'
import { ArrowUpIcon, LinkIcon, CalendarIcon, ViewIcon, WarningIcon, StarIcon, TimeIcon } from '@chakra-ui/icons'
import { Course } from '../../__data__/model'
import { CourseDetails } from './course-details'
export const CourseCard = ({ course }: { course: Course }) => {
const [getLessonList, populatedCourse] = api.useLazyGetCourseByIdQuery()
@ -51,9 +54,25 @@ export const CourseCard = ({ course }: { course: Course }) => {
}),
})
const [isOpened, setIsOpened] = useState(false)
const [isLessonsExpanded, setIsLessonsExpanded] = useState(false)
const { t } = useTranslation()
const { colorMode } = useColorMode()
// Адаптивные размеры и компоновка для различных размеров экрана
const headingSize = useBreakpointValue({ base: 'sm', md: 'md' })
const buttonSize = useBreakpointValue({ base: 'xs', md: 'md' })
const tagSize = useBreakpointValue({ base: 'sm', md: 'md' })
const avatarSize = useBreakpointValue({ base: 'xs', md: 'sm' })
const cardPadding = useBreakpointValue({ base: 2, md: 4 })
// Используем медиа-запросы для определения направления бейджей
const [isLargerThanSm] = useMediaQuery("(min-width: 480px)")
const [badgeDirection, setBadgeDirection] = useState<'column' | 'row'>('row')
useEffect(() => {
setBadgeDirection(isLargerThanSm ? 'row' : 'column')
}, [isLargerThanSm])
useEffect(() => {
if (isOpened) {
getLessonList(course.id, true)
@ -64,6 +83,10 @@ export const CourseCard = ({ course }: { course: Course }) => {
setIsOpened((opened) => !opened)
}, [setIsOpened])
const handleToggleExpand = useCallback(() => {
setIsLessonsExpanded((expanded) => !expanded)
}, [setIsLessonsExpanded])
// Рассчитываем статистику курса и посещаемости
const stats = useMemo(() => {
if (!populatedCourse.data) {
@ -173,9 +196,9 @@ export const CourseCard = ({ course }: { course: Course }) => {
bg={colorMode === 'dark' ? 'gray.700' : 'white'}
borderColor={colorMode === 'dark' ? 'gray.600' : 'gray.200'}
>
<CardHeader pb={2}>
<Flex justify="space-between" align="center">
<Heading as="h2" size="md">
<CardHeader pb={2} px={{ base: 3, md: 5 }}>
<Flex justify="space-between" align="center" flexWrap="wrap">
<Heading as="h2" size={headingSize} mb={{ base: 2, md: 0 }}>
{course.name}
</Heading>
<Tooltip label={isOpened ? t('journal.pl.close') : t('journal.pl.course.viewDetails')}>
@ -190,21 +213,37 @@ export const CourseCard = ({ course }: { course: Course }) => {
/>
</Tooltip>
</Flex>
<HStack spacing={2} mt={2}>
<Badge colorScheme="blue">
<HStack spacing={1}>
<CalendarIcon boxSize="3" />
<Text>{dayjs(course.startDt).format('DD.MM.YYYY')}</Text>
<Flex gap={2} mt={2} flexWrap="wrap">
{badgeDirection === 'column' ? (
<VStack align="start" spacing={2} width="100%">
<Badge colorScheme="blue">
<HStack spacing={1}>
<CalendarIcon boxSize="3" />
<Text>{dayjs(course.startDt).format('DD.MM.YYYY')}</Text>
</HStack>
</Badge>
<Badge colorScheme="purple">
{stats.totalLessons} {t('journal.pl.common.lesson').toLowerCase()}
</Badge>
</VStack>
) : (
<HStack spacing={2}>
<Badge colorScheme="blue">
<HStack spacing={1}>
<CalendarIcon boxSize="3" />
<Text>{dayjs(course.startDt).format('DD.MM.YYYY')}</Text>
</HStack>
</Badge>
<Badge colorScheme="purple">
{stats.totalLessons} {t('journal.pl.common.lesson').toLowerCase()}
</Badge>
</HStack>
</Badge>
<Badge colorScheme="purple">
{stats.totalLessons} {t('journal.pl.common.lesson').toLowerCase()}
</Badge>
</HStack>
)}
</Flex>
</CardHeader>
{!isOpened && (
<CardBody pt={2} pb={3}>
<CardBody pt={2} pb={3} px={{ base: 3, md: 5 }}>
{lessonListLoading ? (
<Flex justify="center" py={3}>
<Spinner size="sm" />
@ -214,7 +253,7 @@ export const CourseCard = ({ course }: { course: Course }) => {
<Text fontSize="sm" fontWeight="medium" mb={2}>
{t('journal.pl.attendance.stats.topStudents')}:
</Text>
<AvatarGroup size="sm" max={3} mb={1}>
<AvatarGroup size={avatarSize} max={3} mb={1}>
{attendanceStats.topStudents.map(student => (
<Avatar
key={student.id}
@ -233,8 +272,8 @@ export const CourseCard = ({ course }: { course: Course }) => {
)}
{isOpened && (
<CardBody pt={2}>
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={4} mb={4}>
<CardBody pt={2} px={{ base: 3, md: 5 }}>
<SimpleGrid columns={{ base: 1, md: 2 }} spacing={{ base: 3, md: 4 }} mb={4}>
<Stat>
<StatLabel>{t('journal.pl.course.completedLessons')}</StatLabel>
<HStack align="baseline">
@ -253,9 +292,9 @@ export const CourseCard = ({ course }: { course: Course }) => {
<Stat>
<StatLabel>{t('journal.pl.course.upcomingLessons')}</StatLabel>
<HStack align="baseline">
<HStack align="baseline" flexWrap="wrap">
<StatNumber>{stats.upcomingLessons}</StatNumber>
<Text color="gray.500">
<Text color="gray.500" fontSize={{ base: 'xs', md: 'sm' }}>
<TimeIcon ml={1} mr={1} />
{populatedCourse.data?.lessons
.filter(lesson => dayjs(lesson.date).isAfter(dayjs()))
@ -290,9 +329,9 @@ export const CourseCard = ({ course }: { course: Course }) => {
<VStack align="stretch" spacing={2}>
{attendanceStats.topStudents.map((student, index) => (
<HStack key={student.id} spacing={2}>
<Avatar size="sm" name={student.name} src={student.avatarUrl} />
<Avatar size={avatarSize} name={student.name} src={student.avatarUrl} />
<Box flex="1">
<Text fontSize="sm" fontWeight="medium">{student.name}</Text>
<Text fontSize="sm" fontWeight="medium" isTruncated maxWidth={{ base: '120px', sm: '100%' }}>{student.name}</Text>
<Progress
value={student.percent}
size="xs"
@ -318,9 +357,9 @@ export const CourseCard = ({ course }: { course: Course }) => {
<VStack align="stretch" spacing={2}>
{attendanceStats.lowAttendanceStudents.map((student) => (
<HStack key={student.id} spacing={2}>
<Avatar size="sm" name={student.name} src={student.avatarUrl} />
<Avatar size={avatarSize} name={student.name} src={student.avatarUrl} />
<Box flex="1">
<Text fontSize="sm" fontWeight="medium">{student.name}</Text>
<Text fontSize="sm" fontWeight="medium" isTruncated maxWidth={{ base: '120px', sm: '100%' }}>{student.name}</Text>
<Progress
value={student.percent}
size="xs"
@ -349,8 +388,19 @@ export const CourseCard = ({ course }: { course: Course }) => {
{!populatedCourse.isFetching && populatedCourse.isSuccess && populatedCourse.data && (
<>
<Heading size="sm" mb={3}>{t('journal.pl.lesson.list')}</Heading>
<VStack align="stretch" spacing={2} maxH="300px" overflowY="auto" pr={2}>
<Flex justify="space-between" align="center" mb={3}>
<Heading size="sm">{t('journal.pl.lesson.list')}</Heading>
<Tooltip label={isLessonsExpanded ? t('journal.pl.lesson.collapse') : t('journal.pl.lesson.expand')}>
<IconButton
aria-label={isLessonsExpanded ? t('journal.pl.lesson.collapse') : t('journal.pl.lesson.expand')}
icon={isLessonsExpanded ? <Icon as={FaCompress} /> : <Icon as={FaExpand} />}
size="xs"
onClick={handleToggleExpand}
/>
</Tooltip>
</Flex>
<VStack align="stretch" spacing={2} maxH={isLessonsExpanded ? "none" : "300px"} overflowY={isLessonsExpanded ? "visible" : "auto"} pr={2}>
{[...populatedCourse.data.lessons]
.sort((a, b) => dayjs(b.date).valueOf() - dayjs(a.date).valueOf())
.map(lesson => {
@ -379,10 +429,18 @@ export const CourseCard = ({ course }: { course: Course }) => {
(colorMode === 'dark' ? 'blue.400' : 'blue.500')
}
>
<Flex justify="space-between" align="center">
<Flex justify="space-between" align={{ base: 'flex-start', sm: 'center' }} flexDirection={{ base: 'column', sm: 'row' }} gap={{ base: 2, sm: 0 }}>
<Box>
<Text fontWeight="medium">{lesson.name}</Text>
<HStack spacing={2} mt={1}>
<Text
fontWeight="medium"
fontSize={{ base: 'sm', md: 'md' }}
noOfLines={2}
wordBreak="break-word"
maxWidth={{ base: '100%', sm: '200px', md: '300px' }}
>
{lesson.name}
</Text>
<HStack spacing={2} mt={1} flexWrap="wrap">
<Tag size="sm" colorScheme={isPast ? "green" : "blue"} borderRadius="full">
<TagLeftIcon as={CalendarIcon} boxSize='10px' />
<TagLabel>{dayjs(lesson.date).format('DD.MM.YYYY')}</TagLabel>
@ -405,6 +463,8 @@ export const CourseCard = ({ course }: { course: Course }) => {
variant="ghost"
colorScheme="blue"
leftIcon={<ViewIcon />}
ml={{ base: 0, sm: 'auto' }}
alignSelf={{ base: 'flex-end', sm: 'center' }}
>
{t('journal.pl.common.open')}
</Button>
@ -418,16 +478,17 @@ export const CourseCard = ({ course }: { course: Course }) => {
</CardBody>
)}
<CardFooter pt={2}>
<ButtonGroup spacing={2} width="100%">
<CardFooter pt={2} px={{ base: 3, md: 5 }}>
<ButtonGroup spacing={{ base: 1, md: 2 }} width="100%" flexDirection={{ base: 'column', sm: 'row' }}>
<Tooltip label={t('journal.pl.lesson.list')}>
<Button
leftIcon={<ViewIcon />}
as={ConnectedLink}
colorScheme="blue"
size="md"
size={buttonSize}
flexGrow={1}
to={`${getNavigationValue('journal.main')}/lessons-list/${course._id}`}
mb={{ base: 2, sm: 0 }}
>
{t('journal.pl.lesson.list')}
</Button>
@ -440,7 +501,7 @@ export const CourseCard = ({ course }: { course: Course }) => {
as={ConnectedLink}
variant="outline"
colorScheme="blue"
size="md"
size={buttonSize}
flexGrow={1}
to={generatePath(
`${getNavigationValue('journal.main')}${getNavigationValue('link.journal.attendance')}`,

View File

@ -1,110 +0,0 @@
import React from 'react'
import dayjs from 'dayjs'
import { Link as ConnectedLink } from 'react-router-dom'
import { getNavigationValue, getHistory } from '@brojs/cli'
import { Stack, Heading, Link, Button, Tooltip, Box } from '@chakra-ui/react'
import { useTranslation } from 'react-i18next'
import { LinkIcon } from '@chakra-ui/icons'
import { useAppSelector } from '../../__data__/store'
import { isTeacher } from '../../utils/user'
import { PopulatedCourse } from '../../__data__/model'
import { api } from '../../__data__/api/api'
type CourseDetailsProps = {
populatedCourse: PopulatedCourse
}
const history = getHistory()
export const CourseDetails = ({ populatedCourse }: CourseDetailsProps) => {
const user = useAppSelector((s) => s.user)
const exam = populatedCourse.examWithJury
const [toggleExamWithJury, examWithJuryRequest] =
api.useToggleExamWithJuryMutation()
const { t } = useTranslation()
return (
<>
{isTeacher(user) && (
<Heading as="h3" mt={4} mb={3} size="lg">
{t('journal.pl.exam.title')}: {exam?.name}{' '}
{exam && getNavigationValue('exam.main') && getNavigationValue('link.exam.details') && (
<Tooltip label={t('journal.pl.exam.startExam')} fontSize="12px" top="16px">
<Button
leftIcon={<LinkIcon />}
as={'a'}
colorScheme="blue"
href={
getNavigationValue('exam.main') +
getNavigationValue('link.exam.details')
.replace(':courseId', populatedCourse.id)
.replace(':examId', exam.id)
}
onClick={(event) => {
event.preventDefault()
history.push(
getNavigationValue('exam.main') +
getNavigationValue('link.exam.details')
.replace(':courseId', populatedCourse.id)
.replace(':examId', exam.id),
)
}}
>
{t('journal.pl.exam.open')}
</Button>
</Tooltip>
)}
</Heading>
)}
{!Boolean(exam) && (
<>
<Heading as="h3" mt={4} mb={3} size="lg">
{t('journal.pl.exam.notSpecified')}
</Heading>
<Box mt={10}>
<Tooltip label={t('journal.pl.exam.createWithJury')} fontSize="12px" top="16px">
<Button
colorScheme="blue"
mt={['16px', 0]}
variant="outline"
isLoading={examWithJuryRequest.isLoading}
onClick={() => toggleExamWithJury(populatedCourse.id)}
>
{t('journal.pl.common.create')}
</Button>
</Tooltip>
</Box>
</>
)}
{Boolean(exam) && (
<>
<Heading as="h3" mt={4} mb={3} size="lg">
{t('journal.pl.exam.juryCount')}:
</Heading>
<Heading as="h3" mt={4} mb={3} size="lg">
{populatedCourse.examWithJury.jury.length}
</Heading>
</>
)}
<Heading as="h3" mt={4} mb={3} size="lg">
{t('journal.pl.lesson.list')}:
</Heading>
<Stack>
{populatedCourse?.lessons?.map((lesson) => (
<Link
as={ConnectedLink}
key={lesson.id}
to={
isTeacher(user)
? `${getNavigationValue('journal.main')}/lesson/${populatedCourse.id}/${lesson.id}`
: ''
}
>
{lesson.name}
</Link>
))}
</Stack>
</>
)
}

View File

@ -17,6 +17,9 @@ import {
FormErrorMessage,
useToast,
useColorMode,
useBreakpointValue,
Flex,
Stack
} from '@chakra-ui/react'
import { useForm, Controller } from 'react-hook-form'
import { AddIcon } from '@chakra-ui/icons'
@ -45,6 +48,12 @@ export const CoursesList = () => {
const { colorMode } = useColorMode();
// Определяем размеры для адаптивного дизайна
const buttonSize = useBreakpointValue({ base: 'md', md: 'lg' })
const headingSize = useBreakpointValue({ base: 'md', md: 'lg' })
const formSpacing = useBreakpointValue({ base: 5, md: 10 })
const containerPadding = useBreakpointValue({ base: '2', md: '4' })
const {
control,
handleSubmit,
@ -80,6 +89,7 @@ export const CoursesList = () => {
})
}
reset()
setShowForm(false) // Закрываем форму после успешного создания
}
}, [crucQuery.isSuccess, t])
@ -90,20 +100,20 @@ export const CoursesList = () => {
}
return (
<Container maxW="container.xl">
<Container maxW="container.xl" px={containerPadding}>
{isTeacher(user) && (
<Box mt="15" mb="15">
<Box mt={{ base: 3, md: 5 }} mb={{ base: 3, md: 5 }}>
{showForm ? (
<Card align="left">
<CardHeader display="flex">
<Heading as="h2" mt="0">
<CardHeader display="flex" flexWrap="wrap" alignItems="center">
<Heading as="h2" size={headingSize} mt="0" flex="1" mr={2} mb={{ base: 2, md: 0 }}>
{t('journal.pl.course.createTitle')}
</Heading>
<CloseButton ml="auto" onClick={() => setShowForm(false)} />
<CloseButton ml={{ base: 'auto', md: 0 }} onClick={() => setShowForm(false)} />
</CardHeader>
<CardBody>
<form onSubmit={handleSubmit(onSubmit)}>
<VStack spacing={10} align="left">
<VStack spacing={formSpacing} align="left">
<Controller
control={control}
name="startDt"
@ -160,38 +170,54 @@ export const CoursesList = () => {
)}
/>
<Box mt={10}>
<Button
size="lg"
type="submit"
leftIcon={<AddIcon />}
colorScheme="blue"
<Flex mt={formSpacing} justifyContent={{ base: 'center', md: 'flex-start' }}>
<Stack direction={{ base: 'column', sm: 'row' }} spacing={2} width={{ base: '100%', sm: 'auto' }}>
<Button
size={buttonSize}
type="submit"
leftIcon={<AddIcon />}
colorScheme="blue"
width={{ base: '100%', sm: 'auto' }}
isLoading={crucQuery.isLoading}
>
{t('journal.pl.common.create')}
</Button>
</Box>
{t('journal.pl.common.create')}
</Button>
<Button
size={buttonSize}
variant="outline"
width={{ base: '100%', sm: 'auto' }}
onClick={() => setShowForm(false)}
>
{t('journal.pl.common.cancel')}
</Button>
</Stack>
</Flex>
</VStack>
{crucQuery?.error && (
<ErrorSpan>{(crucQuery?.error as any).error}</ErrorSpan>
<Box mt={4}>
<ErrorSpan>{(crucQuery?.error as any).error}</ErrorSpan>
</Box>
)}
</form>
</CardBody>
</Card>
) : (
<Box p="2" m="2">
<Box p={{ base: 1, md: 2 }} m={{ base: 1, md: 2 }}>
<Button
leftIcon={<AddIcon />}
colorScheme="green"
onClick={() => setShowForm(true)}
>
size={buttonSize}
width={{ base: '100%', sm: 'auto' }}
>
{t('journal.pl.common.add')}
</Button>
</Box>
)}
</Box>
)}
<VStack as="ul" align="stretch">
<VStack as="ul" align="stretch" spacing={{ base: 3, md: 4 }}>
{data?.body?.map((c) => (
<CourseCard
key={c.id}

View File

@ -1,7 +1,7 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import dayjs from 'dayjs'
import { generatePath, Link, useParams } from 'react-router-dom'
import { getNavigationsValue, getFeatures } from '@brojs/cli'
import { getNavigationValue, getFeatures } from '@brojs/cli'
import {
Breadcrumb,
BreadcrumbItem,
@ -219,7 +219,7 @@ const LessonList = () => {
<BreadcrumbsWrapper>
<Breadcrumb>
<BreadcrumbItem>
<BreadcrumbLink as={Link} to={getNavigationsValue('journal.main')}>
<BreadcrumbLink as={Link} to={getNavigationValue('journal.main')}>
{t('journal.pl.common.journal')}
</BreadcrumbLink>
</BreadcrumbItem>