Merge branch 'feature/lesson-page' into develop

This commit is contained in:
Sergey Yarkov 2022-01-02 22:28:30 +03:00
commit e62757ab18
24 changed files with 549 additions and 78 deletions

View File

@ -1 +1 @@
export const BACKEND_URL: string = 'http://localhost:3333'; export const BACKEND_URL = 'http://localhost:3333';

22
package-lock.json generated
View File

@ -1741,6 +1741,11 @@
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
"dev": true "dev": true
}, },
"deepmerge": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz",
"integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg=="
},
"define-properties": { "define-properties": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz",
@ -2849,6 +2854,11 @@
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.1.6.tgz", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.1.6.tgz",
"integrity": "sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA=" "integrity": "sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA="
}, },
"load-script": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/load-script/-/load-script-1.0.0.tgz",
"integrity": "sha1-BJGTngvuVkPuSUp+PaPSuscMbKQ="
},
"lodash": { "lodash": {
"version": "4.17.21", "version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
@ -3324,6 +3334,18 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
}, },
"react-player": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/react-player/-/react-player-2.9.0.tgz",
"integrity": "sha512-jNUkTfMmUhwPPAktAdIqiBcVUKsFKrVGH6Ocutj6535CNfM91yrvWxHg6fvIX8Y/fjYUPoejddwh7qboNV9vGA==",
"requires": {
"deepmerge": "^4.0.0",
"load-script": "^1.0.0",
"memoize-one": "^5.1.1",
"prop-types": "^15.7.2",
"react-fast-compare": "^3.0.1"
}
},
"react-redux": { "react-redux": {
"version": "7.2.6", "version": "7.2.6",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.6.tgz", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.6.tgz",

View File

@ -28,6 +28,7 @@
"react-helmet": "^6.1.0", "react-helmet": "^6.1.0",
"react-hook-form": "^7.12.2", "react-hook-form": "^7.12.2",
"react-icons": "^4.3.1", "react-icons": "^4.3.1",
"react-player": "^2.9.0",
"react-router-dom": "^5.2.0", "react-router-dom": "^5.2.0",
"react-spinners": "^0.11.0", "react-spinners": "^0.11.0",
"react-virtuoso": "^2.2.8", "react-virtuoso": "^2.2.8",

View File

@ -0,0 +1,25 @@
import { yupResolver } from '@hookform/resolvers/yup';
import React from 'react';
import { SubmitHandler, useForm } from 'react-hook-form';
import { LessonForm } from '.';
import { InputFields } from './LessonForm';
import LessonFormSchema from './LessonForm.validator';
const CreateLessonForm: React.FC = () => {
const form = useForm<InputFields>({ resolver: yupResolver(LessonFormSchema) });
const onSubmit: SubmitHandler<InputFields> = async data => {
console.log('creating...');
};
return (
<LessonForm
isLoading={false}
reactHookForm={{ ...form }}
onSubmit={form.handleSubmit(onSubmit)}
buttonLabel='Create'
/>
);
};
export default CreateLessonForm;

View File

@ -0,0 +1,82 @@
import React from 'react';
import {
FormControl,
FormHelperText,
FormLabel,
Text,
Input,
InputGroup,
Stack,
Box,
Button,
Textarea,
Flex,
} from '@chakra-ui/react';
import { UseFormReturn } from 'react-hook-form';
export type InputFields = {
title: string;
description: string;
};
export type LessonFormPropsType = {
onSubmit: () => Promise<void>;
buttonLabel: string;
isLoading: boolean;
reactHookForm: UseFormReturn<InputFields>;
};
const LessonForm: React.FC<LessonFormPropsType> = ({ onSubmit, buttonLabel, isLoading, reactHookForm }) => {
const {
control,
register,
watch,
formState: { errors, isDirty },
} = reactHookForm;
return (
<form onSubmit={onSubmit}>
<Stack spacing={'5'}>
<FormControl isRequired id='title' isInvalid={!!errors.title}>
<FormLabel>Title</FormLabel>
<InputGroup>
<Input placeholder='Lesson name' type='text' {...register('title')} />
</InputGroup>
<FormHelperText>e.g Variables in Javascript</FormHelperText>
<Text as='small' color='red.500'>
{errors.title?.message}
</Text>
</FormControl>
<FormControl isRequired id='description' isInvalid={!!errors.description}>
<FormLabel>Description</FormLabel>
<Textarea resize='none' minH='150px' placeholder='Course description...' {...register('description')} />
<Flex justifyContent='space-between'>
<Text as='small' color='red.500'>
{errors.description?.message}
</Text>
</Flex>
</FormControl>
<FormControl isRequired id='video'>
<FormLabel>Video</FormLabel>
</FormControl>
<Box mt='5'>
<Button
isLoading={isLoading}
isDisabled={!isDirty}
loadingText='Saving...'
type='submit'
colorScheme='blue'
variant='outline'
>
{buttonLabel}
</Button>
</Box>
</Stack>
</form>
);
};
export default LessonForm;

View File

@ -0,0 +1,11 @@
import yup from '@educt/schema';
const LessonFormSchema = yup.object().shape({
title: yup.string().required('Title field name is required').max(90, 'Title must be at most 90 characters'),
description: yup
.string()
.required('Description field name is required')
.max(250, 'Description must be at most 250 characters'),
});
export default LessonFormSchema;

View File

@ -0,0 +1,3 @@
import LessonForm from './LessonForm';
export { LessonForm };

View File

@ -1,7 +1,8 @@
import React from 'react'; import React from 'react';
import { BoxProps, Box, Heading, Text, Divider } from '@chakra-ui/react'; import { BoxProps, Box, Flex, Heading, Text, Divider } from '@chakra-ui/react';
interface IPageHeadingProps extends BoxProps { interface IPageHeadingProps extends BoxProps {
headingPrefix?: JSX.Element;
heading: string; heading: string;
description?: string; description?: string;
} }
@ -11,10 +12,13 @@ export const PageWrapper: React.FC<BoxProps> = props => {
}; };
export const PageHeading: React.FC<IPageHeadingProps> = props => { export const PageHeading: React.FC<IPageHeadingProps> = props => {
const { heading, description, ...boxProps } = props; const { headingPrefix, heading, description, ...boxProps } = props;
return ( return (
<Box {...boxProps}> <Box {...boxProps}>
<Flex alignItems={'center'}>
{headingPrefix}
<Heading as='h1'>{heading}</Heading> <Heading as='h1'>{heading}</Heading>
</Flex>
{description && <Text mt='2'>{description}</Text>} {description && <Text mt='2'>{description}</Text>}
{props.children} {props.children}
<Divider mt='5' /> <Divider mt='5' />

View File

@ -0,0 +1,21 @@
import useAsync from '@educt/hooks/useAsync';
import { ILesson } from '@educt/interfaces';
import { CourseServiceInstance } from '@educt/services';
const useFetchCourseLessons = () => {
const fetch = async (id: string) => {
try {
const { data } = await CourseServiceInstance.fetchLessonsById(id);
return data;
} catch (error) {
console.error(error);
return Promise.reject(error);
}
};
const { execute, ...state } = useAsync<Array<ILesson>, Parameters<typeof fetch>>(fetch);
return { fetchCourseLessons: execute, ...state };
};
export { useFetchCourseLessons };

View File

@ -1,12 +1,28 @@
import useAsync from '@educt/hooks/useAsync';
import { ILesson } from '@educt/interfaces'; import { ILesson } from '@educt/interfaces';
import { LessonServiceInstance } from '@educt/services'; import { LessonServiceInstance } from '@educt/services';
import useAsync from '@educt/hooks/useAsync';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useToast } from '@chakra-ui/react';
import { useErrorHandler } from 'react-error-boundary';
const useFetchLesson = (id: string) => { const useFetchLesson = (id: string) => {
const toast = useToast();
const handleError = useErrorHandler();
const fetch = async (id: string) => { const fetch = async (id: string) => {
try {
const { data } = await LessonServiceInstance.fetchLessonById(id); const { data } = await LessonServiceInstance.fetchLessonById(id);
return data; return data;
} catch (error: any) {
if (error.response) {
if (error.response.status === 403) {
toast({ title: 'You are not able to view this lesson.', status: 'warning' });
}
} else {
handleError(error);
}
return Promise.reject(error);
}
}; };
const { execute: fetchLessonById, ...state } = useAsync<ILesson, Parameters<typeof fetch>>(fetch); const { execute: fetchLessonById, ...state } = useAsync<ILesson, Parameters<typeof fetch>>(fetch);

View File

@ -0,0 +1,22 @@
import { LessonServiceInstance } from '@educt/services';
import type { LessonProgress } from '@educt/types';
import useAsync from '../../useAsync';
const useFetchLessonProgress = () => {
const fetch = async (id: string) => {
try {
const result = await LessonServiceInstance.fetchProgress(id);
return result.data;
} catch (error) {
console.error(error);
return Promise.reject(error);
}
};
const { execute, ...state } = useAsync<LessonProgress, Parameters<typeof fetch>>(fetch);
return { fetchLessonProgress: execute, ...state };
};
export { useFetchLessonProgress };

View File

@ -1,5 +1,5 @@
import type { RouteComponentProps } from 'react-router-dom'; import type { RouteComponentProps } from 'react-router-dom';
import { AttachmentFileType, ColorType, LinkType } from '@educt/types'; import { AttachmentFileType, ColorType, LessonProgress, LessonVideoType, LinkType } from '@educt/types';
import { CourseStatusEnum, UserRoleEnum } from '../enums'; import { CourseStatusEnum, UserRoleEnum } from '../enums';
export interface IAppConfig { export interface IAppConfig {
@ -27,7 +27,7 @@ export interface ICourse {
status: CourseStatusEnum; status: CourseStatusEnum;
teacher: Pick<IUser, 'id' | 'first_name' | 'fullname' | 'last_name' | 'email' | 'created_at' | 'updated_at'>; teacher: Pick<IUser, 'id' | 'first_name' | 'fullname' | 'last_name' | 'email' | 'created_at' | 'updated_at'>;
category: ICategory; category: ICategory;
lessons: ILesson[]; lessons: Omit<ILesson, 'materials' | 'content' | 'progress'>[];
students: IUser[]; students: IUser[];
color: ColorType | null; color: ColorType | null;
students_count: string; students_count: string;
@ -45,10 +45,31 @@ export interface ILesson {
duration: string; duration: string;
color?: ColorType | undefined | null; color?: ColorType | undefined | null;
materials_count?: string | undefined; materials_count?: string | undefined;
materials: Array<ILessonMaterial>;
progress: LessonProgress;
content: ILessonContent;
video?: LessonVideoType | undefined | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
export interface ILessonMaterial {
id: number;
lesson_id: string;
name: string;
client_name: string;
size: number;
ext: string;
created_at: string;
updated_at: string;
}
export interface ILessonContent {
id: number;
lesson_id: string;
video_url: string;
}
export interface ICategory { export interface ICategory {
id: string; id: string;
title: string; title: string;

View File

@ -24,6 +24,7 @@ import LoadingPage from '@educt/components/LoadingPage';
import { useHistory, 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 { useRootStore } from '@educt/hooks/useRootStore'; import { useRootStore } from '@educt/hooks/useRootStore';
import { Helmet } from 'react-helmet';
const CoursePage: React.FC<IPageProps> = () => { const CoursePage: React.FC<IPageProps> = () => {
const { const {
@ -47,6 +48,9 @@ const CoursePage: React.FC<IPageProps> = () => {
return ( return (
<PageWrapper maxW='1200px'> <PageWrapper maxW='1200px'>
<Helmet>
<title>{course.title}</title>
</Helmet>
<CourseHeader> <CourseHeader>
<CourseHeading title={course.title} teacher={course.teacher} category={course.category} /> <CourseHeading title={course.title} teacher={course.teacher} category={course.category} />
<CourseInfo <CourseInfo

View File

@ -1,16 +1,20 @@
import React from 'react'; import React from 'react';
import { Box, Heading } from '@chakra-ui/react';
import { IPageProps } from '@educt/interfaces'; import { IPageProps } from '@educt/interfaces';
import { PageContent, PageHeading, PageWrapper } from '@educt/components/PageElements';
import CreateLessonForm from '@educt/components/Forms/LessonForm/CreateLessonForm';
/** /**
* Create Lesson page * Create Lesson page
*/ */
const CreateLessonPage: React.FC<IPageProps> = () => { const CreateLessonPage: React.FC<IPageProps> = () => {
return ( return (
<Box> <PageWrapper>
<Heading as='h1'>Create Lesson page</Heading> <PageHeading heading='Lesson Creation' description='Create new lesson ' />
</Box> <PageContent>
<CreateLessonForm />
</PageContent>
</PageWrapper>
); );
}; };

View File

@ -11,24 +11,23 @@ import { IPageProps } from '@educt/interfaces';
*/ */
import PrevPageButton from '@educt/components/PrevPageButton'; import PrevPageButton from '@educt/components/PrevPageButton';
import { CreateCourseForm } from '@educt/components/Forms/CourseForm'; import { CreateCourseForm } from '@educt/components/Forms/CourseForm';
import { PageContent, PageHeading, PageWrapper } from '@educt/components/PageElements';
/** /**
* Create course page * Create course page
*/ */
const CreateCoursePage: React.FC<IPageProps> = () => { const CreateCoursePage: React.FC<IPageProps> = () => {
return ( return (
<Box> <PageWrapper>
<Box> <PageHeading
<Flex alignItems='center'> headingPrefix={<PrevPageButton prevPage='/courses' />}
<PrevPageButton prevPage='/courses' /> heading='Course Creation'
<Heading as='h1'>Course creation</Heading> description='Fill in the required fields to create a new course'
</Flex> />
<Text mt='3'>Fill in the required fields to create a course</Text> <PageContent>
</Box>
<Box mt='5'>
<CreateCourseForm /> <CreateCourseForm />
</Box> </PageContent>
</Box> </PageWrapper>
); );
}; };

View File

@ -56,13 +56,13 @@ const CreateLessonButton: React.FC<{ id: string }> = ({ id }) => {
const LessonList: React.FC<LessonListPropsType> = ({ course }) => { const LessonList: React.FC<LessonListPropsType> = ({ course }) => {
const history = useHistory(); const history = useHistory();
const { onOpen: onOpenDelDialog, onClose: onCloseDelDialog, isOpen: isOpenDelDialog } = useDisclosure(); const { onOpen: onOpenDelDialog, onClose: onCloseDelDialog, isOpen: isOpenDelDialog } = useDisclosure();
const [lessons, setLessons] = useState<Array<ILesson>>(course.lessons); const [lessons, setLessons] = useState<ICourse['lessons']>(course.lessons);
const [deleting, setDeleting] = useState<Pick<ILesson, 'id' | 'title'> | null>(null); const [deleting, setDeleting] = useState<Pick<ILesson, 'id' | 'title'> | null>(null);
const handleError = useErrorHandler(); const handleError = useErrorHandler();
const handleCreateLesson = (): void => history.push('/lessons/create'); const handleCreateLesson = (): void => history.push(`/courses/edit/${course.id}/create-lesson`);
const handleEditLesson = (id: string): void => history.push(`/lessons/edit/${id}`); const handleEditLesson = (id: string): void => history.push(`/lessons/edit/${id}`);
const handleDeleteLesson = (lesson: ILesson): void => { const handleDeleteLesson = (lesson: ICourse['lessons'][number]): void => {
setDeleting({ id: lesson.id, title: lesson.title }); setDeleting({ id: lesson.id, title: lesson.title });
onOpenDelDialog(); onOpenDelDialog();
}; };
@ -93,7 +93,7 @@ const LessonList: React.FC<LessonListPropsType> = ({ course }) => {
index, index,
}: { }: {
provided: DraggableProvided; provided: DraggableProvided;
lesson: ILesson; lesson: ICourse['lessons'][number];
isDragging: boolean; isDragging: boolean;
index: number; index: number;
}) => { }) => {

View File

@ -14,6 +14,10 @@ import type { IPageProps } from '@educt/interfaces';
import LoadingPage from '@educt/components/LoadingPage'; import LoadingPage from '@educt/components/LoadingPage';
import { EditCourseForm } from '@educt/components/Forms/CourseForm'; import { EditCourseForm } from '@educt/components/Forms/CourseForm';
import DeleteCourseDialog from '@educt/components/Dialogs/DeleteCourseDialog'; import DeleteCourseDialog from '@educt/components/Dialogs/DeleteCourseDialog';
import PrevPageButton from '@educt/components/PrevPageButton';
import { DeleteButton } from '@educt/components/Buttons';
import { StatusButton } from './components';
import { PageContent, PageHeading, PageWrapper } from '@educt/components/PageElements';
import { StudentTableList, StudentTableRow } from './components/StudentTableList'; import { StudentTableList, StudentTableRow } from './components/StudentTableList';
import LessonList from './components/LessonList'; import LessonList from './components/LessonList';
@ -23,9 +27,6 @@ import LessonList from './components/LessonList';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { useFetchCourse } from '@educt/hooks/queries'; import { useFetchCourse } from '@educt/hooks/queries';
import { useDisclosure } from '@chakra-ui/hooks'; import { useDisclosure } from '@chakra-ui/hooks';
import { PageContent, PageHeading, PageWrapper } from '@educt/components/PageElements';
import { DeleteButton } from '@educt/components/Buttons';
import { StatusButton } from './components';
/** /**
* Course editor * Course editor
@ -54,7 +55,11 @@ const EditCoursePage: React.FC<IPageProps> = () => {
</Helmet> </Helmet>
<DeleteCourseDialog course={course} onClose={onCloseDeleteDialog} isOpen={isOpenDeleteDialog} /> <DeleteCourseDialog course={course} onClose={onCloseDeleteDialog} isOpen={isOpenDeleteDialog} />
<PageHeading heading='Course editor' description={course.title}> <PageHeading
headingPrefix={<PrevPageButton prevPage='/courses' />}
heading='Course editor'
description={course.title}
>
<Flex mt='5' justifyContent='flex-end'> <Flex mt='5' justifyContent='flex-end'>
<StatusButton courseId={course.id} currentStatus={course.status} /> <StatusButton courseId={course.id} currentStatus={course.status} />
<DeleteButton onClick={onOpenDeleteDialog} ml='2' /> <DeleteButton onClick={onOpenDeleteDialog} ml='2' />

View File

@ -1,53 +1,186 @@
import React from 'react'; import React from 'react';
import moment from 'moment'; import moment from 'moment';
import { Flex, Box, Button, Divider, Heading, Text, Grid, GridItem, Stack, Icon, IconButton } from '@chakra-ui/react'; import * as helpres from '@educt/helpers';
import { Link as ReactRouterLink, Redirect } from 'react-router-dom';
import { Helmet } from 'react-helmet';
import axios from 'axios';
import {
Flex,
Box,
Button,
Divider,
Heading,
Text,
Grid,
LinkBox,
LinkOverlay,
GridItem,
Stack,
Icon,
IconButton,
} from '@chakra-ui/react';
import ReactPlayer from 'react-player';
import { MdOutlineCircle, MdCheckCircle, MdOutlineFilePresent, MdSkipNext, MdSkipPrevious } from 'react-icons/md';
import LoadingPage from '@educt/components/LoadingPage';
import { PageContent, PageHeading, PageWrapper } from '@educt/components/PageElements';
import { EditIcon } from '@chakra-ui/icons';
/** /**
* Types * Types
*/ */
import { IPageProps } from '@educt/interfaces'; import { ILesson, ILessonMaterial, IPageProps } from '@educt/interfaces';
/** /**
* Hooks * Hooks
*/ */
import { Redirect, useParams } from 'react-router-dom'; import { useEffect, useRef, useState } from 'react';
import { PageContent, PageHeading, PageWrapper } from '@educt/components/PageElements'; import { useHistory, useParams } from 'react-router-dom';
import { useRootStore } from '@educt/hooks/useRootStore';
import { useColorModeValue, useToast } from '@chakra-ui/react';
import { useFetchLesson } from '@educt/hooks/queries/lesson/useFetchLesson'; import { useFetchLesson } from '@educt/hooks/queries/lesson/useFetchLesson';
import LoadingPage from '@educt/components/LoadingPage'; import { useFetchCourseLessons } from '@educt/hooks/queries/course/useFetchCourseLessons';
import { MdCheckCircle, MdOutlineFilePresent, MdSkipNext, MdSkipPrevious } from 'react-icons/md'; import { useFetchLessonProgress } from '@educt/hooks/queries/lesson/useFetchLessonProgress';
/**
* Services
*/
import { LessonServiceInstance } from '@educt/services';
/** /**
* Lesson Page * Lesson Page
*/ */
const LessonPage: React.FC<IPageProps> = () => { const LessonPage: React.FC<IPageProps> = () => {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const {
userStore: { me },
} = useRootStore();
const { error, data: lesson, isLoading } = useFetchLesson(id); const { error, data: lesson, isLoading } = useFetchLesson(id);
const { fetchCourseLessons, data: lessons } = useFetchCourseLessons();
const { fetchLessonProgress } = useFetchLessonProgress();
const history = useHistory();
const toast = useToast();
const [nextLesson, setNextLesson] = useState<ILesson | null>(null);
const [prevLesson, setPrevLesson] = useState<ILesson | null>(null);
const currentLessonRef = useRef<HTMLDivElement | null>(null);
const bg = useColorModeValue('gray.50', 'gray.700');
useEffect(() => {
currentLessonRef.current?.scrollIntoView({ block: 'center' });
window.scrollTo(0, 0);
/**
* Define next and prev lessons
*/
if (lessons) {
const currentLessonIndex = lessons.findIndex(lesson => lesson.id === id);
setNextLesson(lessons[currentLessonIndex + 1] || null);
setPrevLesson(lessons[currentLessonIndex - 1] || null);
}
}, [lessons]);
useEffect(() => {
if (lesson !== null) {
(async () => {
await fetchCourseLessons(lesson.course_id);
lesson.video && (await fetchLessonProgress(lesson.id));
})();
}
}, [lesson]);
/**
* Lesson not found
*/
if (error?.response?.status === 404) { if (error?.response?.status === 404) {
return <Redirect to='/404' />; return <Redirect to='/404' />;
} }
if (!lesson || isLoading) return <LoadingPage />; /**
* Unavailable lesson for authorized user
*/
if (error?.response?.status === 403) {
return <Redirect to={`/courses`} />;
}
if (!lesson || !lessons || isLoading) return <LoadingPage />;
const handleNextLesson = () => nextLesson && history.push(`/lesson/${nextLesson.id}`);
const handlePrevLesson = () => prevLesson && history.push(`/lesson/${prevLesson.id}`);
const handleEditLesson = () => history.push(`/lessons/edit/${id}`);
const handleDownloadMaterial =
({ name, client_name }: ILessonMaterial) =>
async () => {
try {
const blob = await LessonServiceInstance.fetchMaterial(name);
if (blob instanceof Blob) {
const link = document.createElement('a');
link.href = window.URL.createObjectURL(blob);
link.download = `${client_name}`;
link.click();
window.URL.revokeObjectURL(link.href);
}
} catch (error: unknown) {
if (axios.isAxiosError(error)) {
if (error.response) {
if (error.response.status === 404) {
toast({ title: 'File not found.', status: 'error' });
}
}
} else {
console.error(error);
}
}
};
if (me === null) return <LoadingPage />;
return ( return (
<PageWrapper> <PageWrapper>
<Helmet>
<title>{lesson.title}</title>
</Helmet>
<PageHeading heading={lesson.title}> <PageHeading heading={lesson.title}>
<Box mt='3'> <Flex mt='3' alignItems={'center'} justifyContent={'space-between'}>
<Text fontSize={'sm'}>~{moment.duration(lesson.duration, 'minutes').humanize()}</Text> <Text fontSize={'sm'}>~{moment.duration(lesson.duration, 'minutes').humanize()}</Text>
</Box> {(me.isAdmin || me.isTeacher) && (
<Button onClick={handleEditLesson} leftIcon={<EditIcon />} variant={'outline'} size='sm'>
Edit
</Button>
)}
</Flex>
</PageHeading> </PageHeading>
<PageContent> <PageContent>
<Grid templateColumns={{ base: '1fr', xl: '3fr 1fr' }} gap={'7'}> <Grid templateColumns={{ base: '1fr', xl: '3fr 1fr' }}>
<GridItem> <GridItem>
<Box> <Box>
<Box bg={lesson.color?.hex} minH={'500px'} /> <ReactPlayer width={'100%'} height={'100%'} controls url={lesson.video?.url} />
{!lesson.video && (
<Flex h='500px' justifyContent={'center'} alignItems={'center'}>
<Text color='gray.500' userSelect={'none'}>
Video has not been uploaded yet.
</Text>
</Flex>
)}
</Box> </Box>
<Flex justifyContent={'flex-end'} mt='3'> <Flex justifyContent={'flex-end'} mt='3'>
<IconButton aria-label='prev-lesson' icon={<MdSkipPrevious />} size='sm' mr='2' /> {prevLesson && (
<Button aria-label='next-lesson' leftIcon={<MdSkipNext />} size='sm'> <IconButton
onClick={handlePrevLesson}
aria-label='prev-lesson'
icon={<MdSkipPrevious />}
size='sm'
mr='2'
/>
)}
{nextLesson && (
<Button onClick={handleNextLesson} aria-label='next-lesson' leftIcon={<MdSkipNext />} size='sm'>
Next lesson Next lesson
</Button> </Button>
)}
</Flex> </Flex>
<Box mt='7'> <Box mt='7'>
@ -59,43 +192,83 @@ const LessonPage: React.FC<IPageProps> = () => {
<Divider mt='4' /> <Divider mt='4' />
</Box> </Box>
</GridItem> </GridItem>
<GridItem> <GridItem minWidth={'250px'}>
<Box> <Box>
<Stack maxH={'445px'} overflowY={'scroll'}> <Stack maxH={'500px'} overflowY={'scroll'} spacing={'0'}>
{Array(50) {lessons.map((lesson, i) => (
.fill(1) <LinkBox
.map((n, i) => ( ref={lesson.id === id ? currentLessonRef : undefined}
<Flex key={i} alignItems={'center'} borderRadius={'xl'}> as='div'
<Icon as={MdCheckCircle} color='green.500' w={7} h={7} mr='2' /> display={'flex'}
minH='100'
key={lesson.id}
bg={lesson.id === id ? bg : ''}
p='4'
alignItems={'center'}
_hover={{ bg }}
>
<Icon
as={lesson.progress ? MdCheckCircle : MdOutlineCircle}
color={lesson.progress ? 'green.500' : 'gray.500'}
w={6}
h={6}
mr='2'
/>
<Box> <Box>
<Text as='small' color='gray.500'> <Text as='small' color='gray.500'>
Lesson {i + 1} Lesson {i + 1}
</Text> </Text>
<LinkOverlay as={ReactRouterLink} to={`/lesson/${lesson.id}`}>
<Text fontSize='sm' fontWeight={'medium'}> <Text fontSize='sm' fontWeight={'medium'}>
Lorem ipsum dolor sit amet consectetur adipisicing elit. Vel, eaque. {lesson.title}
</Text> </Text>
</LinkOverlay>
</Box> </Box>
</Flex> </LinkBox>
))} ))}
</Stack> </Stack>
<Divider mt='5' mb='5' /> <Box p='5'>
<Box>
<Text fontSize={'2xl'} fontWeight={'medium'}> <Text fontSize={'2xl'} fontWeight={'medium'}>
Materials Materials
</Text> </Text>
{lesson.materials.length !== 0 ? (
<Stack> <Stack>
{[1, 2, 3].map(n => ( {lesson.materials.map(material => (
<Flex key={n} alignItems={'center'} mt='5' p='3' bg='gray.50' borderRadius={'lg'}> <Flex
key={material.id}
alignItems={'center'}
mt='5'
p='3'
bg={bg}
borderRadius={'lg'}
_hover={{ textDecor: 'underline' }}
>
<Icon as={MdOutlineFilePresent} mr='2' h={4} w={4} /> <Icon as={MdOutlineFilePresent} mr='2' h={4} w={4} />
<Text fontSize={'sm'} fontWeight={'medium'} lineHeight={'1rem'}> <Text
lesson-1_sourse-code.zip <br /> onClick={handleDownloadMaterial(material)}
<Text as='small'>190kB</Text> cursor={'pointer'}
overflow={'hidden'}
fontSize={'sm'}
fontWeight={'medium'}
lineHeight={'1rem'}
>
{material.client_name}
<br />
<Text as='small' color='gray.500'>
{helpres.transformBytes(material.size)}
</Text>
</Text> </Text>
</Flex> </Flex>
))} ))}
</Stack> </Stack>
) : (
<Box textAlign={'center'} mt='3'>
<Text color='gray.500' fontSize={'sm'}>
There are no materials
</Text>
</Box>
)}
</Box> </Box>
</Box> </Box>
</GridItem> </GridItem>

View File

@ -37,6 +37,7 @@ const CourseItem: React.FC<CourseItemPropsType> = ({ course }) => {
</Text> </Text>
<Box> <Box>
<Tag <Tag
size='sm'
color='white' color='white'
boxShadow={`inset 0 0 0px 1px ${course.category.color?.hex}`} boxShadow={`inset 0 0 0px 1px ${course.category.color?.hex}`}
bg={course.category.color?.hex} bg={course.category.color?.hex}

View File

@ -60,8 +60,10 @@ export const ProfileDescription: React.FC<IProfileDescriptionProps> = props => {
{email} {email}
</Text> </Text>
</Heading> </Heading>
<Box>
<UserBadge mt='2' roles={roles} /> <UserBadge mt='2' roles={roles} />
</Box> </Box>
</Box>
</Flex> </Flex>
</Box> </Box>
); );

View File

@ -1,7 +1,7 @@
import { AxiosInstance } from 'axios'; import { AxiosInstance } from 'axios';
import { ApiServiceInstance } from '.'; import { ApiServiceInstance } from '.';
import * as helpers from '@educt/helpers'; import * as helpers from '@educt/helpers';
import { IApiRespose, ICourse, IUser } from '@educt/interfaces'; import { IApiRespose, ICourse, ILesson, IUser } from '@educt/interfaces';
import { CourseStatusEnum } from '@educt/enums'; import { CourseStatusEnum } from '@educt/enums';
import { CreateCourseParamsType, FetchCoursesParams, UpdateCourseParamsType } from '@educt/types'; import { CreateCourseParamsType, FetchCoursesParams, UpdateCourseParamsType } from '@educt/types';
@ -40,6 +40,17 @@ class CourseService {
return result.data; return result.data;
} }
/**
* Fetch lessons by course id
*
* @param id Course id
* @returns Array of lessons
*/
public async fetchLessonsById(id: string): Promise<IApiRespose<Array<ILesson>>> {
const result = await this.api.get(`/v1/courses/${id}/lessons`);
return result.data;
}
/** /**
* Update status of course by id * Update status of course by id
* *

View File

@ -1,6 +1,7 @@
import { AxiosInstance } from 'axios'; import { AxiosInstance } from 'axios';
import { IApiRespose, ILesson } from '@educt/interfaces'; import { IApiRespose, ILesson } from '@educt/interfaces';
import { ApiServiceInstance } from './ApiService'; import { ApiServiceInstance } from './ApiService';
import type { LessonProgress } from '@educt/types';
class LessonService { class LessonService {
public api: AxiosInstance; public api: AxiosInstance;
@ -20,6 +21,28 @@ class LessonService {
return result.data; return result.data;
} }
/**
* Download material file by file name
*
* @param fileName File name
* @returns Blob
*/
public async fetchMaterial(fileName: string): Promise<Blob> {
const result = await this.api.get(`/v1/lessons/materials/${fileName}`, { responseType: 'blob' });
return result.data;
}
/**
* Get progress about lesson
*
* @param id Lesson id
* @returns Progress info
*/
public async fetchProgress(id: string): Promise<IApiRespose<LessonProgress>> {
const result = await this.api.get(`/v1/lessons/${id}/progress`);
return result.data;
}
/** /**
* Delete lesson by id * Delete lesson by id
* *

17
src/types/index.d.ts vendored
View File

@ -34,6 +34,23 @@ export type ColorType = {
hex: string; hex: string;
}; };
export type LessonProgress = {
progress: {
is_watched: boolean;
} | null;
};
export type LessonVideoType = {
id: number;
client_name: string;
ext: string;
name: string;
size: number;
url: string;
created_at: '2022-01-02T21:39:08.481+03:00';
updated_at: string;
};
export type ConfirmEmailContainerDataType = { export type ConfirmEmailContainerDataType = {
newEmail: string; newEmail: string;
expired_seconds: number; expired_seconds: number;

View File

@ -29,6 +29,10 @@ export default defineConfig({
target: constants.BACKEND_URL, target: constants.BACKEND_URL,
changeOrigin: true, changeOrigin: true,
}, },
'/video': {
target: constants.BACKEND_URL,
changeOrigin: true,
},
}, },
}, },
resolve: { resolve: {