Merge branch 'feature/course-page' into develop

This commit is contained in:
Sergey Yarkov 2021-12-18 15:23:36 +03:00
commit 329995fcda
15 changed files with 465 additions and 24 deletions

View File

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" translate="no">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />

View File

@ -1,11 +1,9 @@
import React from 'react';
import BaseModal from '@educt/components/Modal';
import * as helpers from '@educt/helpers';
import { FormControl, FormLabel, FormHelperText, Input, Select, Stack } from '@chakra-ui/react';
import { CheckIcon } from '@chakra-ui/icons';
import { MdModeEdit } from 'react-icons/md';
import { yupResolver } from '@hookform/resolvers/yup';
import BaseModal from '@educt/components/Modal';
/**
* Types
@ -49,6 +47,14 @@ const EditUserModal: React.FC<EditUserModalPropsType> = props => {
userStore: { me },
} = useRootStore();
const { updateUser, isLoading } = useUpdateUser();
const defaultValues = {
login: null,
first_name: user.first_name,
last_name: user.last_name,
email: user.email,
role: user.roles[0].slug,
password: null,
};
const {
register,
handleSubmit,
@ -57,7 +63,7 @@ const EditUserModal: React.FC<EditUserModalPropsType> = props => {
} = useForm<UpdateUserInputType>({
resolver: yupResolver(EditUserSchema),
defaultValues: useMemo(() => {
return props.user;
return defaultValues;
}, [props]),
});
@ -78,7 +84,7 @@ const EditUserModal: React.FC<EditUserModalPropsType> = props => {
};
useEffect(() => {
reset(props.user);
reset(defaultValues);
}, [props.user]);
return (

View File

@ -1,5 +1,5 @@
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 {
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) {
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

@ -10,15 +10,13 @@ import { CourseServiceInstance } from '@educt/services';
import { useEffect } from 'react';
import useAsync from '../../useAsync';
type CourseResultDataType = Omit<ICourse, 'students_count' | 'likes_count' | 'lessons_count'>;
const useFetchCourse = (id: string) => {
const fetch = async (id: string) => {
const { data } = await CourseServiceInstance.fetchById(id);
return data;
};
const { execute: fetchCourseById, ...state } = useAsync<CourseResultDataType, Parameters<typeof fetch>>(fetch);
const { execute: fetchCourseById, ...state } = useAsync<ICourse, Parameters<typeof fetch>>(fetch);
useEffect(() => {
fetchCourseById(id);

View File

@ -1,7 +1,8 @@
import { ChakraProvider, ColorModeScript, theme } from '@chakra-ui/react';
import * as React from 'react';
import ReactDOM from 'react-dom';
import { ChakraProvider, ColorModeScript } from '@chakra-ui/react';
import App from './App';
import { theme } from './theme';
import { RootStoreProvider } from './providers';
ReactDOM.render(

View File

@ -107,6 +107,7 @@ export interface IMe extends IUser {
* does not have the STUDENT role
*/
courses?: UserCourseType[];
likes: Array<Pick<ICourse, 'id'>>;
isAdmin: boolean;
isTeacher: boolean;
isStudent: boolean;

View File

@ -1,7 +1,7 @@
import React from 'react';
import Helmet from 'react-helmet';
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';
/**
@ -21,7 +21,7 @@ const NotFoundPage: React.FC<IPageProps> = ({ title }) => {
useEffect(() => {
toast({ title: `Page not found`, duration: 10000, isClosable: true, status: 'warning' });
});
}, []);
return (
<>

View File

@ -2,7 +2,7 @@ import React from 'react';
import Helmet from 'react-helmet';
import { Redirect } from 'react-router-dom';
import { observer } from 'mobx-react';
import { Box, Heading } from '@chakra-ui/react';
import { Box } from '@chakra-ui/react';
/**
* Types
@ -41,11 +41,8 @@ const AuthPage: React.FC<IPageProps> = ({ title }) => {
<Helmet>
<title>{title}</title>
</Helmet>
<Box maxW='md' mx='auto'>
<Logo mx='auto' h='14' mb={{ base: '10', md: '20' }} />
<Heading mb='8' textAlign='center' size='xl' fontWeight='extrabold'>
Login to your account
</Heading>
<Box maxW='md' mx='auto' mt='20'>
<Logo mx='auto' h='14' mb={{ base: '10', md: '10' }} />
<Card>
<AuthForm />
</Card>

View File

@ -84,7 +84,7 @@ const AuthForm: React.FC = () => {
<Button
type='submit'
colorScheme='blue'
size='lg'
size='md'
fontSize='md'
loadingText='Logging in...'
isLoading={isLoading}

157
src/pages/course/course.tsx Normal file
View File

@ -0,0 +1,157 @@
import React from 'react';
import { observer } from 'mobx-react';
import * as helpres from '@educt/helpers';
import { Redirect } from 'react-router-dom';
import { Flex, Box, Text, Heading, Button, Grid, GridItem, Divider, AvatarGroup, Avatar } from '@chakra-ui/react';
import { MdPlayCircleOutline } from 'react-icons/md';
import { Tabs, TabList, TabPanels, Tab, TabPanel } from '@chakra-ui/react';
/**
* Types
*/
import type { IPageProps } from '@educt/interfaces';
/**
* Components
*/
import { CourseBackgroundImage, CourseHeader, CourseHeading, CourseInfo, LessonItem, LessonList } from '.';
import { PageWrapper, PageContent } from '@educt/components/PageElements';
import LoadingPage from '@educt/components/LoadingPage';
/**
* Hooks
*/
import { useHistory, useParams } from 'react-router-dom';
import { useFetchCourse } from '@educt/hooks/queries';
import { useRootStore } from '@educt/hooks/useRootStore';
const CoursePage: React.FC<IPageProps> = () => {
const {
userStore: { me },
} = useRootStore();
const params = useParams<{ id: string }>();
const { error, data: course, isLoading } = useFetchCourse(params.id);
const history = useHistory();
if (error?.response?.status === 404) {
return <Redirect to='/404' />;
}
if (!me || !course || isLoading) return <LoadingPage />;
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 (
<PageWrapper maxW='1200px'>
<CourseHeader>
<CourseHeading title={course.title} teacher={course.teacher} category={course.category} />
<CourseInfo
id={course.id}
lessonsCount={course.lessons_count}
studentsCount={course.students_count}
likesCount={course.likes_count}
/>
</CourseHeader>
<PageContent mt={{ base: '5', sm: '2' }}>
<Grid templateColumns={{ base: '1fr', xl: '3fr 1fr' }} gap={'7'}>
<GridItem>
<Box>
<Flex justifyContent={'space-between'} alignItems={'center'} mb='2'>
<Text fontSize={'sm'}>Last update: {new Date(course.updated_at).toLocaleDateString()}</Text>
<Button
isDisabled={!isCourseAvailable}
onClick={handleWatchCourse}
leftIcon={<MdPlayCircleOutline />}
size='sm'
variant={'outline'}
>
Watch
</Button>
</Flex>
<CourseBackgroundImage bg={course.color?.hex} src={course.image?.url} />
</Box>
<Box mt='4'>
<Tabs>
<TabList>
<Tab>About</Tab>
</TabList>
<TabPanels>
<TabPanel>
<Box mt='6'>
<Heading as='h2' fontSize='2xl' fontWeight={'medium'}>
About this course
</Heading>
<Text mt='2'>{course.description}</Text>
</Box>
{!isEmptyStudents ? (
<Flex justifyContent={'flex-end'} alignItems={'center'} mt='5'>
<Text fontSize={'sm'} mr='2'>
Students:
</Text>
<AvatarGroup size='sm' max={2}>
{course.students.map(student => (
<Avatar key={student.id} name={student.fullname} />
))}
</AvatarGroup>
</Flex>
) : (
<Box textAlign={'right'}>
<Text fontSize={'sm'} userSelect={'none'} color='gray.500'>
0 students
</Text>
</Box>
)}
</TabPanel>
</TabPanels>
</Tabs>
</Box>
<Divider mt='5' />
</GridItem>
<GridItem borderRadius={'lg'} minWidth={'290px'} h='550px'>
<Box>
<Text fontSize={'2xl'} fontWeight={'medium'}>
What you&apos;ll learn.
</Text>
<Text fontSize={'sm'} mt='2'>
Lorem, ipsum dolor sit amet consectetur adipisicing elit. Iste temporibus harum eos praesentium hic,
sunt corporis deserunt impedit natus consequuntur?
</Text>
</Box>
<Divider mt='5' />
<Box mt='4'>
<Flex alignItems={'center'} justifyContent={'space-between'}>
<Text fontSize={'2xl'} fontWeight={'medium'}>
Content
</Text>
<Text as='span' color='gray.500' fontSize={'sm'}>
{course.lessons_count} lessons
</Text>
</Flex>
{!isEmptyLessons ? (
<LessonList course={course} render={LessonItem} />
) : (
<Box textAlign={'center'} p='15px 0'>
<Text fontSize={'sm'} userSelect={'none'} color='gray.500'>
There are no lessons here yet
</Text>
</Box>
)}
<Divider mt='5' />
</Box>
</GridItem>
</Grid>
</PageContent>
</PageWrapper>
);
};
export default observer(CoursePage);

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

@ -0,0 +1,223 @@
import React from 'react';
import { runInAction } from 'mobx';
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 } from '@educt/interfaces';
/**
* Hooks
*/
import { useState } from 'react';
import { useColorModeValue } from '@chakra-ui/react';
import { useRootStore } from '@educt/hooks/useRootStore';
import { CourseServiceInstance } from '@educt/services';
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 { userStore } = useRootStore();
const { me } = userStore;
const { id, lessonsCount, studentsCount, likesCount, ...boxProps } = props;
const [isLoading, setIsLoading] = useState(false);
const [likes, setLikes] = useState<number>(Number.parseInt(likesCount, 10));
const handleLikeCourse = (id: string) => async () => {
if (isLoading || me === null) {
return;
}
try {
const isCourseLiked = me.likes.some(course => course.id === id);
const setCourseLike = () => me.likes.push({ id });
const unsetCourseLike = () => (me.likes = me.likes.filter(course => course.id !== id));
if (!isCourseLiked) {
setIsLoading(true);
setLikes(likes => likes + 1);
await CourseServiceInstance.setLike(id);
runInAction(setCourseLike);
} else {
setIsLoading(true);
setLikes(likes => likes - 1);
await CourseServiceInstance.unsetLike(id);
runInAction(unsetCourseLike);
}
} finally {
setIsLoading(false);
}
};
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'>
{likes}
</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>
);
};

View File

@ -8,6 +8,7 @@ import AuthPage from '@educt/pages/auth/auth';
import NotFoundPage from '@educt/pages/404/404';
import CreateCoursePage from '@educt/pages/courses/create/create';
import CreateLessonPage from '@educt/pages/courses/create-lesson/create-lesson';
import CoursePage from '@educt/pages/course/course';
import EditCoursePage from '@educt/pages/courses/edit/edit';
import ChangePasswordPage from '@educt/pages/profile/change-password/change-password';
import ChangeEmailPage from '@educt/pages/profile/change-email/change-email';
@ -32,6 +33,13 @@ export const routes: IRouteOption[] = [
exact: true,
isPrivate: true,
},
{
path: '/course/:id',
title: 'Course',
component: CoursePage,
isPrivate: true,
exact: true,
},
{
path: '/courses',
title: 'Courses',

View File

@ -35,9 +35,7 @@ class CourseService {
* @param id Course id
* @returns Course
*/
public async fetchById(
id: string
): Promise<IApiRespose<Omit<ICourse, 'students_count' | 'likes_count' | 'lessons_count'>>> {
public async fetchById(id: string): Promise<IApiRespose<ICourse>> {
const result = await this.api.get(`/v1/courses/${id}`);
return result.data;
}
@ -56,6 +54,28 @@ class CourseService {
return result.data;
}
/**
* Set course like
*
* @param id Course id
* @returns Empty data
*/
public async setLike(id: string): Promise<IApiRespose<Record<string, never>>> {
const result = await this.api.put(`/v1/courses/${id}/likes`);
return result.data;
}
/**
* Unset course like
*
* @param id Course id
* @returns Empty data
*/
public async unsetLike(id: string): Promise<IApiRespose<Record<string, never>>> {
const result = await this.api.delete(`/v1/courses/${id}/likes`);
return result.data;
}
/**
*
* @param data Data for creating course

View File

@ -62,7 +62,7 @@ export default class UserStore {
public async loadCurrentUserData() {
const result = await UserServiceInstance.fetchMe();
const {
data: { id, first_name, last_name, fullname, email, roles, contacts, courses, created_at, updated_at },
data: { id, first_name, last_name, fullname, email, roles, likes, contacts, courses, created_at, updated_at },
} = result;
runInAction(() => {
@ -73,6 +73,7 @@ export default class UserStore {
email,
roles,
fullname,
likes,
contacts,
courses,
isAdmin: helpers.userContainRoles(roles, [UserRoleEnum.ADMIN]),

24
src/theme.ts Normal file
View File

@ -0,0 +1,24 @@
import { extendTheme } from '@chakra-ui/react';
export const theme = extendTheme({
styles: {
global: {
body: {
scrollbarWidth: 'thin',
scrollbarColor: '#000',
},
'::-webkit-scrollbar': {
width: '5px',
height: '5px',
},
'::-webkit-scrollbar-track': {
backgroundColor: 'gray.100',
borderRadius: '100px',
},
'::-webkit-scrollbar-thumb': {
background: 'blue.600',
borderRadius: '100px',
},
},
},
});