mirror of
https://github.com/sergeyyarkov/educt-client.git
synced 2026-10-11 10:19:22 +03:00
feat: edit lesson page
This commit is contained in:
parent
76f72dc9c4
commit
0dc80fd0e8
@ -1,21 +1,43 @@
|
||||
import React from 'react';
|
||||
import * as helpres from '@educt/helpers';
|
||||
import React, { useState } from 'react';
|
||||
import * as helpers from '@educt/helpers';
|
||||
import { Stack, Flex, Box, Icon, Text, Input, IconButton, Button } from '@chakra-ui/react';
|
||||
import { MdOutlineFilePresent } from 'react-icons/md';
|
||||
import { AddIcon, CloseIcon } from '@chakra-ui/icons';
|
||||
import { MdAttachFile, MdOutlineFilePresent } from 'react-icons/md';
|
||||
import { CloseIcon } from '@chakra-ui/icons';
|
||||
|
||||
/**
|
||||
* Hooks
|
||||
*/
|
||||
import { useRef } from 'react';
|
||||
import { FileSupportedFormatsEnum } from '@educt/enums';
|
||||
import { AttachmentFileType } from '@educt/types';
|
||||
|
||||
type FilesUploaderPropsType = {
|
||||
onChange: (files: File[]) => void;
|
||||
files?: File[] | undefined;
|
||||
preloadedFiles?: AttachmentFileType[] | undefined;
|
||||
};
|
||||
|
||||
const FilesUploader: React.FC<FilesUploaderPropsType> = ({ onChange, files }) => {
|
||||
const FileContent: React.FC<{ name: string; size: number }> = ({ name, size }) => (
|
||||
<Flex alignItems={'center'}>
|
||||
<Icon as={MdOutlineFilePresent} mr='2' h={4} w={4} />
|
||||
<Text overflow={'hidden'} fontSize={'sm'} fontWeight={'medium'} lineHeight={'1rem'}>
|
||||
{name}
|
||||
<br />
|
||||
<Text as='small' color='gray.500'>
|
||||
{helpers.transformBytes(size)}
|
||||
</Text>
|
||||
</Text>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
const FileWrapper: React.FC = ({ children }) => (
|
||||
<Flex alignItems={'center'} justifyContent={'space-between'} p='3' bg={'gray.50'} borderRadius={'lg'}>
|
||||
{children}
|
||||
</Flex>
|
||||
);
|
||||
|
||||
const FilesUploader: React.FC<FilesUploaderPropsType> = ({ onChange, files, preloadedFiles }) => {
|
||||
const [preloaded, setPreloaded] = useState<AttachmentFileType[]>(preloadedFiles || []);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const onSelectFileHandler = () => {
|
||||
@ -59,27 +81,30 @@ const FilesUploader: React.FC<FilesUploaderPropsType> = ({ onChange, files }) =>
|
||||
.map(ext => `.${ext.toLowerCase()}`)
|
||||
.join(', ')}
|
||||
/>
|
||||
{preloadedFiles && files?.length === 0 && (
|
||||
<Stack>
|
||||
{preloaded.map((file, index) => (
|
||||
<FileWrapper key={index}>
|
||||
<FileContent name={file.name} size={file.size} />
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
setPreloaded(prev => prev.filter(file => file.size !== file.size));
|
||||
onChange([]);
|
||||
}}
|
||||
aria-label='remove'
|
||||
variant={'unstyled'}
|
||||
size='xs'
|
||||
icon={<CloseIcon />}
|
||||
/>
|
||||
</FileWrapper>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
{files && (
|
||||
<Stack>
|
||||
{files.map((file, index) => (
|
||||
<Flex
|
||||
key={index}
|
||||
alignItems={'center'}
|
||||
justifyContent={'space-between'}
|
||||
p='3'
|
||||
bg={'gray.50'}
|
||||
borderRadius={'lg'}
|
||||
>
|
||||
<Flex alignItems={'center'}>
|
||||
<Icon as={MdOutlineFilePresent} mr='2' h={4} w={4} />
|
||||
<Text overflow={'hidden'} fontSize={'sm'} fontWeight={'medium'} lineHeight={'1rem'}>
|
||||
{file.name}
|
||||
<br />
|
||||
<Text as='small' color='gray.500'>
|
||||
{helpres.transformBytes(file.size)}
|
||||
</Text>
|
||||
</Text>
|
||||
</Flex>
|
||||
<FileWrapper key={index}>
|
||||
<FileContent name={file.name} size={file.size} />
|
||||
<IconButton
|
||||
onClick={onRemoveSelectedFileHandler(file)}
|
||||
aria-label='remove'
|
||||
@ -87,12 +112,24 @@ const FilesUploader: React.FC<FilesUploaderPropsType> = ({ onChange, files }) =>
|
||||
size='xs'
|
||||
icon={<CloseIcon />}
|
||||
/>
|
||||
</Flex>
|
||||
</FileWrapper>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
<Button onClick={onSelectFileHandler} float={'right'} leftIcon={<AddIcon />} variant={'outline'} size='sm' mt='3'>
|
||||
Upload new
|
||||
{files?.length === 0 && preloaded?.length === 0 && (
|
||||
<Text color={'gray.500'} userSelect={'none'} fontSize={'sm'} textAlign={'center'}>
|
||||
No file selected
|
||||
</Text>
|
||||
)}
|
||||
<Button
|
||||
onClick={onSelectFileHandler}
|
||||
float={'right'}
|
||||
leftIcon={<MdAttachFile />}
|
||||
variant={'outline'}
|
||||
size='sm'
|
||||
mt='3'
|
||||
>
|
||||
Select files
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@ -7,7 +7,7 @@ import { LessonForm } from '.';
|
||||
/**
|
||||
* Validator
|
||||
*/
|
||||
import LessonFormSchema from './LessonForm.validator';
|
||||
import { CreateLessonFormSchema } from './LessonForm.validator';
|
||||
|
||||
/**
|
||||
* Hooks
|
||||
@ -16,7 +16,7 @@ import { useCreateLesson } from '@educt/hooks/queries';
|
||||
import { useHistory, useParams } from 'react-router-dom';
|
||||
|
||||
const CreateLessonForm: React.FC = () => {
|
||||
const form = useForm<InputFields>({ resolver: yupResolver(LessonFormSchema) });
|
||||
const form = useForm<InputFields>({ resolver: yupResolver(CreateLessonFormSchema) });
|
||||
const { createLesson, isLoading } = useCreateLesson();
|
||||
const { id: course_id } = useParams<{ id: string }>();
|
||||
const history = useHistory();
|
||||
|
||||
62
src/components/Forms/LessonForm/EditLessonForm.tsx
Normal file
62
src/components/Forms/LessonForm/EditLessonForm.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
import * as helpers from '@educt/helpers';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { SubmitHandler, useForm } from 'react-hook-form';
|
||||
import type { InputFields } from './LessonForm';
|
||||
import { LessonForm } from '.';
|
||||
|
||||
/**
|
||||
* Validator
|
||||
*/
|
||||
import { EditLessonFormSchema } from './LessonForm.validator';
|
||||
|
||||
/**
|
||||
* Hooks
|
||||
*/
|
||||
import { useHistory, useParams } from 'react-router-dom';
|
||||
import { ILesson } from '@educt/interfaces';
|
||||
|
||||
type EditLessonFormPropsType = {
|
||||
lesson: ILesson;
|
||||
};
|
||||
|
||||
const EditLessonForm: React.FC<EditLessonFormPropsType> = ({ lesson }) => {
|
||||
const form = useForm<InputFields>({
|
||||
resolver: yupResolver(EditLessonFormSchema),
|
||||
defaultValues: {
|
||||
title: lesson.title,
|
||||
description: lesson.description,
|
||||
duration: lesson.duration,
|
||||
video: undefined,
|
||||
materials: undefined,
|
||||
},
|
||||
});
|
||||
const { id: lesson_id } = useParams<{ id: string }>();
|
||||
const history = useHistory();
|
||||
|
||||
const onSubmit: SubmitHandler<InputFields> = async data => {
|
||||
const updatedFields = helpers.getDirtyFields(form.formState.dirtyFields, data);
|
||||
console.log(updatedFields);
|
||||
};
|
||||
|
||||
return (
|
||||
<LessonForm
|
||||
isLoading={false}
|
||||
reactHookForm={{ ...form }}
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
buttonLabel='Save'
|
||||
preloadedVideo={lesson.video || undefined}
|
||||
preloadedMaterials={lesson.materials.map(material => {
|
||||
return {
|
||||
name: material.client_name,
|
||||
extname: material.ext,
|
||||
size: material.size,
|
||||
url: `materials/${material.name}`,
|
||||
mimeType: '',
|
||||
};
|
||||
})}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditLessonForm;
|
||||
@ -17,6 +17,7 @@ import { Controller, UseFormReturn } from 'react-hook-form';
|
||||
import VideoUploader from '@educt/components/VideoUploader';
|
||||
import FilesUploader from '@educt/components/FilesUploader';
|
||||
import { FileSupportedFormatsEnum, VideoSupportedFormatsEnum } from '@educt/enums';
|
||||
import type { AttachmentFileType, LessonVideoType } from '@educt/types';
|
||||
|
||||
export type InputFields = {
|
||||
title: string;
|
||||
@ -31,9 +32,18 @@ export type LessonFormPropsType = {
|
||||
buttonLabel: string;
|
||||
isLoading: boolean;
|
||||
reactHookForm: UseFormReturn<InputFields>;
|
||||
preloadedVideo?: LessonVideoType | undefined;
|
||||
preloadedMaterials?: AttachmentFileType[] | undefined;
|
||||
};
|
||||
|
||||
const LessonForm: React.FC<LessonFormPropsType> = ({ onSubmit, buttonLabel, isLoading, reactHookForm }) => {
|
||||
const LessonForm: React.FC<LessonFormPropsType> = ({
|
||||
onSubmit,
|
||||
buttonLabel,
|
||||
isLoading,
|
||||
reactHookForm,
|
||||
preloadedVideo,
|
||||
preloadedMaterials,
|
||||
}) => {
|
||||
const {
|
||||
control,
|
||||
register,
|
||||
@ -70,7 +80,7 @@ const LessonForm: React.FC<LessonFormPropsType> = ({ onSubmit, buttonLabel, isLo
|
||||
control={control}
|
||||
name='video'
|
||||
render={({ field: { onChange, value: file } }) => (
|
||||
<VideoUploader onChange={file => onChange(file)} file={file} />
|
||||
<VideoUploader onChange={file => onChange(file)} file={file} preloadedVideoUrl={preloadedVideo?.url} />
|
||||
)}
|
||||
/>
|
||||
<Text as='small' color='red.500'>
|
||||
@ -99,7 +109,11 @@ const LessonForm: React.FC<LessonFormPropsType> = ({ onSubmit, buttonLabel, isLo
|
||||
control={control}
|
||||
name='materials'
|
||||
render={({ field: { onChange, value: files } }) => (
|
||||
<FilesUploader onChange={files => onChange(files)} files={Array.from(files || [])} />
|
||||
<FilesUploader
|
||||
onChange={files => onChange(files)}
|
||||
files={Array.from(files || [])}
|
||||
preloadedFiles={preloadedMaterials}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Text as='small' color='red.500'>
|
||||
|
||||
@ -4,7 +4,7 @@ import yup from '@educt/schema';
|
||||
const VIDEO_FILE_SIZE = 5000 * 1024 * 1024;
|
||||
const MATERIAL_FILE_SIZE = 100000 * 1024;
|
||||
|
||||
const LessonFormSchema = yup
|
||||
const CreateLessonFormSchema = yup
|
||||
.object({
|
||||
title: yup.string().required('Title field name is required').max(90, 'Title must be at most 90 characters'),
|
||||
description: yup
|
||||
@ -13,28 +13,29 @@ const LessonFormSchema = yup
|
||||
.max(250, 'Description must be at most 250 characters'),
|
||||
duration: yup.string().required('Duration field name is required'),
|
||||
video: yup
|
||||
.mixed()
|
||||
.test(
|
||||
'fileSize',
|
||||
`File size is too large, maximum size is ${helpers.transformBytes(VIDEO_FILE_SIZE)}`,
|
||||
(value: File) => {
|
||||
if (!value) return true;
|
||||
return value && value.size <= VIDEO_FILE_SIZE;
|
||||
}
|
||||
.mixed<File>()
|
||||
.test('fileSize', `File size is too large, maximum size is ${helpers.transformBytes(VIDEO_FILE_SIZE)}`, value =>
|
||||
!value ? true : value.size <= VIDEO_FILE_SIZE
|
||||
)
|
||||
.required('Video field name is required'),
|
||||
materials: yup
|
||||
.mixed()
|
||||
.mixed<File[]>()
|
||||
.test(
|
||||
'fileSize',
|
||||
`File size is too large, maximum size is ${helpers.transformBytes(MATERIAL_FILE_SIZE)}`,
|
||||
(values: File[]) => {
|
||||
if (!values) return true;
|
||||
return values && values.every(value => value.size <= MATERIAL_FILE_SIZE);
|
||||
}
|
||||
values => (!values ? true : values.every(value => value.size <= MATERIAL_FILE_SIZE))
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.required();
|
||||
|
||||
export default LessonFormSchema;
|
||||
const EditLessonFormSchema = CreateLessonFormSchema.shape({
|
||||
video: yup
|
||||
.mixed<File>()
|
||||
.test('fileSize', `File size is too large, maximum size is ${helpers.transformBytes(VIDEO_FILE_SIZE)}`, value =>
|
||||
!value ? true : value.size <= VIDEO_FILE_SIZE
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export { CreateLessonFormSchema, EditLessonFormSchema };
|
||||
|
||||
@ -2,16 +2,19 @@ import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Flex, Box, Icon, Text, Button, Input, AspectRatio, useColorModeValue } from '@chakra-ui/react';
|
||||
import { MdUpload } from 'react-icons/md';
|
||||
import { VideoSupportedFormatsEnum } from '@educt/enums';
|
||||
import { LessonVideoType } from '@educt/types';
|
||||
|
||||
type VideoUploaderPropsType = {
|
||||
onChange: (file: File) => void;
|
||||
file?: File | undefined;
|
||||
file?: File | LessonVideoType | undefined;
|
||||
preloadedVideoUrl?: string | undefined;
|
||||
};
|
||||
|
||||
const VideoUploader: React.FC<VideoUploaderPropsType> = ({ onChange, file }) => {
|
||||
const VideoUploader: React.FC<VideoUploaderPropsType> = ({ onChange, file, preloadedVideoUrl }) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [videoObjectUrl, setVideoObjectUrl] = useState<string | undefined>(undefined);
|
||||
const isPreloadedVideo = preloadedVideoUrl !== undefined;
|
||||
|
||||
const onSelectFileHandler = () => {
|
||||
if (fileInputRef.current) {
|
||||
@ -38,12 +41,24 @@ const VideoUploader: React.FC<VideoUploaderPropsType> = ({ onChange, file }) =>
|
||||
}
|
||||
};
|
||||
|
||||
const ChangeVideoButton = () => (
|
||||
<Button onClick={onSelectFileHandler} leftIcon={<MdUpload />} variant={'outline'} size='sm' mt='2' float='right'>
|
||||
Change video
|
||||
</Button>
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.load();
|
||||
}
|
||||
}, [videoObjectUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (file && file instanceof File) {
|
||||
setVideoObjectUrl(URL.createObjectURL(file));
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{file && videoObjectUrl ? (
|
||||
@ -53,18 +68,9 @@ const VideoUploader: React.FC<VideoUploaderPropsType> = ({ onChange, file }) =>
|
||||
<source src={videoObjectUrl} />
|
||||
</video>
|
||||
</AspectRatio>
|
||||
<Button
|
||||
onClick={onSelectFileHandler}
|
||||
leftIcon={<MdUpload />}
|
||||
variant={'outline'}
|
||||
size='sm'
|
||||
mt='2'
|
||||
float='right'
|
||||
>
|
||||
Change video
|
||||
</Button>
|
||||
<ChangeVideoButton />
|
||||
</Box>
|
||||
) : (
|
||||
) : !isPreloadedVideo ? (
|
||||
<Flex
|
||||
p='40px 10px'
|
||||
textAlign={'center'}
|
||||
@ -89,6 +95,15 @@ const VideoUploader: React.FC<VideoUploaderPropsType> = ({ onChange, file }) =>
|
||||
Select file
|
||||
</Button>
|
||||
</Flex>
|
||||
) : (
|
||||
<Box>
|
||||
<AspectRatio maxH='lg' mt='2'>
|
||||
<video controls style={{ borderRadius: '5px' }}>
|
||||
<source src={preloadedVideoUrl} />
|
||||
</video>
|
||||
</AspectRatio>
|
||||
<ChangeVideoButton />
|
||||
</Box>
|
||||
)}
|
||||
<Input
|
||||
type='file'
|
||||
@ -97,6 +112,7 @@ const VideoUploader: React.FC<VideoUploaderPropsType> = ({ onChange, file }) =>
|
||||
maxW={'0'}
|
||||
h='0'
|
||||
display={'block'}
|
||||
required={false}
|
||||
opacity={'0'}
|
||||
ml='auto'
|
||||
mr='auto'
|
||||
|
||||
@ -80,6 +80,11 @@ export function transformBytes(bytes: number, decimals = 2): string {
|
||||
return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
export function transformBlobToFile(blob: Blob, fileName: string) {
|
||||
const file = new File([blob], fileName, { type: blob.type });
|
||||
return file;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param array Array
|
||||
|
||||
@ -6,6 +6,7 @@ import { CourseStatusEnum } from '@educt/enums';
|
||||
/**
|
||||
* Hooks
|
||||
*/
|
||||
import { useEffect } from 'react';
|
||||
import { useRootStore } from '@educt/hooks/useRootStore';
|
||||
|
||||
export const CoursesTabs: React.FC<BoxProps> = props => {
|
||||
@ -14,6 +15,10 @@ export const CoursesTabs: React.FC<BoxProps> = props => {
|
||||
pageStore: { coursesStore },
|
||||
} = useRootStore();
|
||||
|
||||
useEffect(() => {
|
||||
coursesStore.setShowedStatus(undefined);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
me &&
|
||||
(me.isAdmin || me.isTeacher ? (
|
||||
|
||||
@ -156,8 +156,9 @@ const LessonPage: React.FC<IPageProps> = () => {
|
||||
<Grid templateColumns={{ base: '1fr', xl: '3fr 1fr' }}>
|
||||
<GridItem>
|
||||
<Box>
|
||||
{lesson.video ? (
|
||||
<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.
|
||||
|
||||
32
src/pages/lessons/edit-lesson/edit-lesson.tsx
Normal file
32
src/pages/lessons/edit-lesson/edit-lesson.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import { IPageProps } from '@educt/interfaces';
|
||||
import { PageContent, PageHeading, PageWrapper } from '@educt/components/PageElements';
|
||||
import { Redirect, useParams } from 'react-router-dom';
|
||||
import LoadingPage from '@educt/components/LoadingPage';
|
||||
import { useFetchLesson } from '@educt/hooks/queries/lesson/useFetchLesson';
|
||||
import EditLessonForm from '@educt/components/Forms/LessonForm/EditLessonForm';
|
||||
|
||||
/**
|
||||
* Create Lesson page
|
||||
*/
|
||||
const EditLessonPage: React.FC<IPageProps> = () => {
|
||||
const params = useParams<{ id: string }>();
|
||||
const { error, data: lesson, isLoading } = useFetchLesson(params.id);
|
||||
|
||||
if (error?.response?.status === 404) {
|
||||
return <Redirect to='/404' />;
|
||||
}
|
||||
|
||||
if (!lesson || isLoading) return <LoadingPage />;
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
<PageHeading heading='Lesson Editor' description={lesson.description} />
|
||||
<PageContent>
|
||||
<EditLessonForm lesson={lesson} />
|
||||
</PageContent>
|
||||
</PageWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditLessonPage;
|
||||
@ -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 EditLessonPage from './pages/lessons/edit-lesson/edit-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';
|
||||
@ -78,6 +79,14 @@ export const routes: IRouteOption[] = [
|
||||
exact: true,
|
||||
roles: [UserRoleEnum.ADMIN, UserRoleEnum.TEACHER],
|
||||
},
|
||||
{
|
||||
path: '/lessons/edit/:id',
|
||||
title: 'Edit Lesson',
|
||||
component: EditLessonPage,
|
||||
isPrivate: true,
|
||||
exact: true,
|
||||
roles: [UserRoleEnum.ADMIN, UserRoleEnum.TEACHER],
|
||||
},
|
||||
{
|
||||
path: '/lesson/:id',
|
||||
title: 'Lesson',
|
||||
|
||||
@ -88,6 +88,7 @@ class CourseService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new course
|
||||
*
|
||||
* @param data Data for creating course
|
||||
* @returns
|
||||
@ -104,6 +105,13 @@ class CourseService {
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update course
|
||||
*
|
||||
* @param id Course id
|
||||
* @param data Data to update
|
||||
* @returns Updated course
|
||||
*/
|
||||
public async update(
|
||||
id: string,
|
||||
data: UpdateCourseParamsType
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user