feat: rendering course page

This commit is contained in:
Sergey Yarkov 2021-12-18 00:17:56 +03:00
parent 63082e50c2
commit 9d67c14fee
5 changed files with 246 additions and 110 deletions

View File

@ -1,5 +1,5 @@
import { UserRoleEnum } from '@educt/enums'; import { UserRoleEnum } from '@educt/enums';
import { IUserRole } from '@educt/interfaces'; import { ICourse, IUserRole, UserCourseType } from '@educt/interfaces';
export function userHasRoles(userRoles: IUserRole[], roles: UserRoleEnum[]): boolean { export function userHasRoles(userRoles: IUserRole[], roles: UserRoleEnum[]): boolean {
for (let i = 0; i < roles.length; i += 1) { for (let i = 0; i < roles.length; i += 1) {
@ -97,3 +97,8 @@ export function arrayMove<T>(array: T[], from: number, to: number) {
export function getDirtyFields<T>(dirtyFields: { [key: string]: boolean | undefined }, data: T) { export function getDirtyFields<T>(dirtyFields: { [key: string]: boolean | undefined }, data: T) {
return Object.fromEntries(Object.keys(dirtyFields).map(k => [k, data[k as keyof T]])); return Object.fromEntries(Object.keys(dirtyFields).map(k => [k, data[k as keyof T]]));
} }
export function userHasCourse(findingCourse: ICourse, userCourses?: Array<UserCourseType> | undefined) {
if (!userCourses) return true;
return userCourses.some(course => course.id === findingCourse.id);
}

View File

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import Helmet from 'react-helmet'; import Helmet from 'react-helmet';
import { Link as ReactRouterLink } from 'react-router-dom'; import { Link as ReactRouterLink } from 'react-router-dom';
import { Flex, Heading, Text, Link, Icon } from '@chakra-ui/react'; import { Heading, Text, Link, Icon } from '@chakra-ui/react';
import { MdError } from 'react-icons/md'; import { MdError } from 'react-icons/md';
/** /**

View File

@ -2,7 +2,7 @@ import React from 'react';
import Helmet from 'react-helmet'; import Helmet from 'react-helmet';
import { Redirect } from 'react-router-dom'; import { Redirect } from 'react-router-dom';
import { observer } from 'mobx-react'; import { observer } from 'mobx-react';
import { Box, Heading } from '@chakra-ui/react'; import { Box } from '@chakra-ui/react';
/** /**
* Types * Types

View File

@ -1,26 +1,9 @@
import React from 'react'; import React from 'react';
import { Redirect, useHistory } from 'react-router-dom'; import * as helpres from '@educt/helpers';
import { import { Redirect } from 'react-router-dom';
Flex, import { Flex, Box, Text, Heading, Button, Grid, GridItem, Divider, AvatarGroup, Avatar } from '@chakra-ui/react';
Box, import { MdPlayCircleOutline } from 'react-icons/md';
Text, import { Tabs, TabList, TabPanels, Tab, TabPanel } from '@chakra-ui/react';
Tag,
Heading,
Icon,
Button,
Grid,
GridItem,
Image,
Divider,
AvatarGroup,
LinkBox,
LinkOverlay,
Avatar,
useColorModeValue,
} from '@chakra-ui/react';
import { MdThumbUpOffAlt, MdPlayCircleOutline, MdOutlineGroup, MdOutlineVideoLibrary } from 'react-icons/md';
import { Tabs, TabList, TabPanels, Tab, TabPanel, Stack } from '@chakra-ui/react';
import { Link as ReactRouterLink } from 'react-router-dom';
/** /**
* Types * Types
@ -30,78 +13,67 @@ import { IPageProps } from '@educt/interfaces';
/** /**
* Components * Components
*/ */
import { CourseBackgroundImage, CourseHeader, CourseHeading, CourseInfo, LessonItem, LessonList } from '.';
import { PageWrapper, PageContent } from '@educt/components/PageElements'; import { PageWrapper, PageContent } from '@educt/components/PageElements';
import LoadingPage from '@educt/components/LoadingPage';
/** /**
* Hooks * Hooks
*/ */
import { useParams } from 'react-router-dom'; import { useHistory, useParams } from 'react-router-dom';
import { useFetchCourse } from '@educt/hooks/queries'; import { useFetchCourse } from '@educt/hooks/queries';
import LoadingPage from '@educt/components/LoadingPage'; import { useRootStore } from '@educt/hooks/useRootStore';
import moment from 'moment'; import { observer } from 'mobx-react';
const CoursePage: React.FC<IPageProps> = () => { const CoursePage: React.FC<IPageProps> = () => {
const {
userStore: { me },
} = useRootStore();
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
const { error, data: course, isLoading } = useFetchCourse(params.id); const { error, data: course, isLoading } = useFetchCourse(params.id);
const history = useHistory(); const history = useHistory();
const color = useColorModeValue('gray.50', 'gray.700');
if (error?.response?.status === 404) { if (error?.response?.status === 404) {
return <Redirect to='/404' />; return <Redirect to='/404' />;
} }
if (!course || isLoading) return <LoadingPage />; if (!me || !course || isLoading) return <LoadingPage />;
const onWatchCourse = () => history.push(`/lesson/${course.lessons[0].id}`); const isCourseAvailable = helpres.userHasCourse(course, me.courses);
const isEmptyLessons = course.lessons.length === 0;
const isEmptyStudents = course.students.length === 0;
const handleWatchCourse = () => history.push(`/lesson/${course.lessons[0].id}`);
return ( return (
<PageWrapper maxW='1200px'> <PageWrapper maxW='1200px'>
<Flex justifyContent='space-between' flexDir={{ base: 'column', xl: 'row' }}> <CourseHeader>
<Box> <CourseHeading title={course.title} teacher={course.teacher} category={course.category} />
<Heading as='h1' fontSize='3xl' mr='4'> <CourseInfo
{course.title} id={course.id}
</Heading> lessonsCount={course.lessons_count}
<Flex mt='3' alignItems={'center'}> studentsCount={course.students_count}
<Text fontSize='sm'>{course.teacher.fullname}</Text>&nbsp;&bull;&nbsp; likesCount={course.likes_count}
<Tag size='sm' variant='solid' bg={course.category.color?.hex}> />
{course.category.title} </CourseHeader>
</Tag>
</Flex>
</Box>
<Box mt={{ base: '2', xl: '0' }}>
<Flex alignItems={'center'}>
<Icon as={MdOutlineVideoLibrary} />
<Text as='span' fontSize={'sm'} ml='1' mr='4'>
{course.lessons_count}
</Text>
<Icon as={MdOutlineGroup} />
<Text as='span' fontSize={'sm'} ml='1' mr='2'>
{course.students_count}
</Text>
<Button leftIcon={<MdThumbUpOffAlt />} variant='ghost'>
{course.likes_count}
</Button>
</Flex>
</Box>
</Flex>
<PageContent mt={{ base: '5', sm: '2' }}> <PageContent mt={{ base: '5', sm: '2' }}>
<Grid templateColumns={{ base: '1fr', xl: '3fr 1fr' }} gap={'7'}> <Grid templateColumns={{ base: '1fr', xl: '3fr 1fr' }} gap={'7'}>
<GridItem> <GridItem>
<Box> <Box>
<Flex justifyContent={'space-between'} alignItems={'center'} mb='2'> <Flex justifyContent={'space-between'} alignItems={'center'} mb='2'>
<Text fontSize={'sm'}>Last update: {new Date(course.updated_at).toLocaleDateString()}</Text> <Text fontSize={'sm'}>Last update: {new Date(course.updated_at).toLocaleDateString()}</Text>
<Button onClick={onWatchCourse} leftIcon={<MdPlayCircleOutline />} size='sm' variant={'outline'}> <Button
isDisabled={!isCourseAvailable}
onClick={handleWatchCourse}
leftIcon={<MdPlayCircleOutline />}
size='sm'
variant={'outline'}
>
Watch Watch
</Button> </Button>
</Flex> </Flex>
<Image <CourseBackgroundImage bg={course.color?.hex} src={course.image?.url} />
bg={course.color?.hex}
borderRadius={'lg'}
objectFit='cover'
w='full'
h={{ base: '300px', sm: '300px', md: '300px', lg: '400px' }}
src={course.image?.url}
/>
</Box> </Box>
<Box mt='4'> <Box mt='4'>
<Tabs> <Tabs>
@ -117,7 +89,7 @@ const CoursePage: React.FC<IPageProps> = () => {
</Heading> </Heading>
<Text mt='2'>{course.description}</Text> <Text mt='2'>{course.description}</Text>
</Box> </Box>
{course.students.length !== 0 ? ( {!isEmptyStudents ? (
<Flex justifyContent={'flex-end'} alignItems={'center'} mt='5'> <Flex justifyContent={'flex-end'} alignItems={'center'} mt='5'>
<Text fontSize={'sm'} mr='2'> <Text fontSize={'sm'} mr='2'>
Students: Students:
@ -152,7 +124,9 @@ const CoursePage: React.FC<IPageProps> = () => {
sunt corporis deserunt impedit natus consequuntur? sunt corporis deserunt impedit natus consequuntur?
</Text> </Text>
</Box> </Box>
<Divider mt='5' /> <Divider mt='5' />
<Box mt='4'> <Box mt='4'>
<Flex alignItems={'center'} justifyContent={'space-between'}> <Flex alignItems={'center'} justifyContent={'space-between'}>
<Text fontSize={'2xl'} fontWeight={'medium'}> <Text fontSize={'2xl'} fontWeight={'medium'}>
@ -162,46 +136,8 @@ const CoursePage: React.FC<IPageProps> = () => {
{course.lessons_count} lessons {course.lessons_count} lessons
</Text> </Text>
</Flex> </Flex>
{course.lessons.length !== 0 ? ( {!isEmptyLessons ? (
<Stack spacing={'1'} mt='2' overflowY={'auto'} maxH={'445px'} pr='5px'> <LessonList course={course} render={LessonItem} />
{course.lessons.map(lesson => {
return (
<LinkBox
as='div'
display={'flex'}
key={lesson.id}
fontSize={'sm'}
justifyContent={'space-between'}
pl='5'
pr='5'
pb='5'
pt='5'
minH='85px'
bg={color}
borderRadius={'xl'}
transition={'ease-in-out .1s'}
_hover={{
bg: 'blue.500',
color: 'white',
}}
>
<Flex alignItems={'center'}>
<Icon as={MdPlayCircleOutline} w={5} h={5} mr='2' />
<LinkOverlay
as={ReactRouterLink}
fontWeight='medium'
to={`/lesson/${lesson.id}`}
overflowY={'hidden'}
maxH='60px'
>
{lesson.title}
</LinkOverlay>
</Flex>
<Text as='span'>{moment.duration(lesson.duration).asMinutes()}min</Text>
</LinkBox>
);
})}
</Stack>
) : ( ) : (
<Box textAlign={'center'} p='15px 0'> <Box textAlign={'center'} p='15px 0'>
<Text fontSize={'sm'} userSelect={'none'} color='gray.500'> <Text fontSize={'sm'} userSelect={'none'} color='gray.500'>
@ -218,4 +154,4 @@ const CoursePage: React.FC<IPageProps> = () => {
); );
}; };
export default CoursePage; export default observer(CoursePage);

195
src/pages/course/index.tsx Normal file
View File

@ -0,0 +1,195 @@
import React from 'react';
import moment from 'moment';
import * as helpers from '@educt/helpers';
import { Link as ReactRouterLink } from 'react-router-dom';
import {
Box,
BoxProps,
Flex,
FlexProps,
Button,
Icon,
Image,
Text,
Heading,
Tag,
Stack,
ImageProps,
LinkBox,
LinkOverlay,
LinkBoxProps,
StackProps,
} from '@chakra-ui/react';
import {
MdThumbUpOffAlt,
MdPlayCircleOutline,
MdOutlineGroup,
MdOutlineVideoLibrary,
MdOutlineLock,
} from 'react-icons/md';
/**
* Types
*/
import type { ICourse, ILesson } from '@educt/interfaces';
/**
* Hooks
*/
import { useColorModeValue } from '@chakra-ui/react';
import { useRootStore } from '@educt/hooks/useRootStore';
export const CourseHeader: React.FC<FlexProps> = props => (
<Flex justifyContent='space-between' flexDir={{ base: 'column', xl: 'row' }} {...props}>
{props.children}
</Flex>
);
interface ICourseHeadingProps extends BoxProps {
title: string;
teacher: ICourse['teacher'];
category: ICourse['category'];
}
export const CourseHeading: React.FC<ICourseHeadingProps> = props => {
const { title, teacher, category, ...boxProps } = props;
return (
<Box {...boxProps}>
<Heading as='h1' fontSize='3xl' mr='4'>
{title}
</Heading>
<Flex mt='3' alignItems={'center'}>
<Text fontSize='sm'>{teacher.fullname}</Text>&nbsp;&bull;&nbsp;
<Tag size='sm' variant='solid' bg={category.color?.hex}>
{category.title}
</Tag>
</Flex>
</Box>
);
};
interface ICourseInfoProps extends BoxProps {
id: string;
lessonsCount: string;
studentsCount: string;
likesCount: string;
}
export const CourseInfo: React.FC<ICourseInfoProps> = props => {
const { id, lessonsCount, studentsCount, likesCount, ...boxProps } = props;
const handleLikeCourse = (id: string) => () => {
// TODO set like request
console.log(`like course: ${id}`);
};
return (
<Box mt={{ base: '2', xl: '0' }} {...boxProps}>
<Flex alignItems={'center'}>
<Icon as={MdOutlineVideoLibrary} />
<Text as='span' fontSize={'sm'} ml='1' mr='4'>
{lessonsCount}
</Text>
<Icon as={MdOutlineGroup} />
<Text as='span' fontSize={'sm'} ml='1' mr='2'>
{studentsCount}
</Text>
<Button onClick={handleLikeCourse(id)} leftIcon={<MdThumbUpOffAlt />} variant='ghost'>
{likesCount}
</Button>
</Flex>
</Box>
);
};
export const CourseBackgroundImage: React.FC<ImageProps> = props => {
return (
<Image
borderRadius={'lg'}
objectFit='cover'
w='full'
h={{ base: '300px', sm: '300px', md: '300px', lg: '400px' }}
{...props}
/>
);
};
interface ILessonListProps extends StackProps {
course: ICourse;
render: React.FC<ILessonItemProps>;
}
export const LessonList: React.FC<ILessonListProps> = props => {
const { course, render: Item, ...stackProps } = props;
const {
userStore: { me },
} = useRootStore();
const bg = useColorModeValue('gray.50', 'gray.700');
if (!me) return null;
const isCourseAvailable = helpers.userHasCourse(course, me.courses);
return (
<Stack spacing={'1'} mt='2' overflowY={'auto'} maxH={'445px'} pr='5px' {...stackProps}>
{course.lessons.map(lesson => (
<Item
key={lesson.id}
id={lesson.id}
title={lesson.title}
duration={lesson.duration}
isDisabled={!isCourseAvailable}
bg={bg}
/>
))}
</Stack>
);
};
interface ILessonItemProps extends LinkBoxProps {
id: string;
title: string;
duration: string;
isDisabled?: boolean | undefined;
}
export const LessonItem: React.FC<ILessonItemProps> = props => {
const { id, title, duration, isDisabled, ...linkBoxProps } = props;
return (
<LinkBox
as='div'
display={'flex'}
fontSize={'sm'}
justifyContent={'space-between'}
pl='5'
pr='5'
pb='5'
pt='5'
minH='85px'
borderRadius={'xl'}
transition={'ease-in-out .1s'}
_hover={
!isDisabled
? {
bg: 'blue.500',
color: 'white',
}
: {}
}
{...linkBoxProps}
>
<Flex alignItems={'center'} fontWeight='medium' userSelect={'none'}>
<Icon as={isDisabled ? MdOutlineLock : MdPlayCircleOutline} w={5} h={5} mr='2' />
{!isDisabled ? (
<LinkOverlay as={ReactRouterLink} to={`/lesson/${id}`} overflowY={'hidden'} maxH='60px'>
{title}
</LinkOverlay>
) : (
<Text overflowY={'hidden'} maxH='60px'>
{title}
</Text>
)}
</Flex>
<Text as='span'>{moment.duration(duration).asMinutes()}min</Text>
</LinkBox>
);
};