feat: upload video to lesson from src link

This commit is contained in:
Sergey Yarkov 2022-06-12 19:56:54 +03:00
parent b4a94e3da5
commit f098b2d096
13 changed files with 135 additions and 65 deletions

53
package-lock.json generated
View File

@ -31,7 +31,7 @@
"react-player": "^2.9.0", "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.13.1",
"socket.io-client": "^4.4.1", "socket.io-client": "^4.4.1",
"validator": "^13.6.0", "validator": "^13.6.0",
"yup": "^0.32.9" "yup": "^0.32.9"
@ -1988,11 +1988,11 @@
} }
}, },
"node_modules/@virtuoso.dev/react-urx": { "node_modules/@virtuoso.dev/react-urx": {
"version": "0.2.8", "version": "0.2.13",
"resolved": "https://registry.npmjs.org/@virtuoso.dev/react-urx/-/react-urx-0.2.8.tgz", "resolved": "https://registry.npmjs.org/@virtuoso.dev/react-urx/-/react-urx-0.2.13.tgz",
"integrity": "sha512-QV9ZJ9Yla88ySgN+DuHLDYoQa0UDRrEq88sLqQESZUWzLughrkpg1BLmQT7FuiU2M78A+rQ9DR3GBlWrJIx+Bw==", "integrity": "sha512-MY0ugBDjFb5Xt8v2HY7MKcRGqw/3gTpMlLXId2EwQvYJoC8sP7nnXjAxcBtTB50KTZhO0SbzsFimaZ7pSdApwA==",
"dependencies": { "dependencies": {
"@virtuoso.dev/urx": "^0.2.8" "@virtuoso.dev/urx": "^0.2.13"
}, },
"engines": { "engines": {
"node": ">=10" "node": ">=10"
@ -2002,9 +2002,9 @@
} }
}, },
"node_modules/@virtuoso.dev/urx": { "node_modules/@virtuoso.dev/urx": {
"version": "0.2.8", "version": "0.2.13",
"resolved": "https://registry.npmjs.org/@virtuoso.dev/urx/-/urx-0.2.8.tgz", "resolved": "https://registry.npmjs.org/@virtuoso.dev/urx/-/urx-0.2.13.tgz",
"integrity": "sha512-40uiTn5z1J/7at9Ao/aesyFhE7sS1cpb2MrbP/JSS6dcLMsQJcAZGedCY3bKkyjANH9DX/ciUpkpmT3TXmUBOQ==" "integrity": "sha512-iirJNv92A1ZWxoOHHDYW/1KPoi83939o83iUBQHIim0i3tMeSKEh+bxhJdTHQ86Mr4uXx9xGUTq69cp52ZP8Xw=="
}, },
"node_modules/@vitejs/plugin-react": { "node_modules/@vitejs/plugin-react": {
"version": "1.0.4", "version": "1.0.4",
@ -4849,18 +4849,19 @@
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="
}, },
"node_modules/react-virtuoso": { "node_modules/react-virtuoso": {
"version": "2.2.8", "version": "2.13.1",
"resolved": "https://registry.npmjs.org/react-virtuoso/-/react-virtuoso-2.2.8.tgz", "resolved": "https://registry.npmjs.org/react-virtuoso/-/react-virtuoso-2.13.1.tgz",
"integrity": "sha512-DArJ29ZrhdygdLKdXs/GB3VNDxaAF7YjdgIjlWPuK6UsV848hNmtw9px3LPdcQBErxaz7XCtHNfc7yInX+/M7g==", "integrity": "sha512-Wek6hYPYU7W1r9fGEF1/sMrFOH8VV8PW64fCRxx9OSSlq5q+5Cp0uf+7eP0jFWqpZtC2DrqFoj+seZXjkiyHrA==",
"dependencies": { "dependencies": {
"@virtuoso.dev/react-urx": "^0.2.8", "@virtuoso.dev/react-urx": "^0.2.12",
"@virtuoso.dev/urx": "^0.2.8" "@virtuoso.dev/urx": "^0.2.12"
}, },
"engines": { "engines": {
"node": ">=10" "node": ">=10"
}, },
"peerDependencies": { "peerDependencies": {
"react": ">=16" "react": ">=16 || >=17 || >= 18",
"react-dom": ">=16 || >=17 || >= 18"
} }
}, },
"node_modules/redux": { "node_modules/redux": {
@ -7131,17 +7132,17 @@
} }
}, },
"@virtuoso.dev/react-urx": { "@virtuoso.dev/react-urx": {
"version": "0.2.8", "version": "0.2.13",
"resolved": "https://registry.npmjs.org/@virtuoso.dev/react-urx/-/react-urx-0.2.8.tgz", "resolved": "https://registry.npmjs.org/@virtuoso.dev/react-urx/-/react-urx-0.2.13.tgz",
"integrity": "sha512-QV9ZJ9Yla88ySgN+DuHLDYoQa0UDRrEq88sLqQESZUWzLughrkpg1BLmQT7FuiU2M78A+rQ9DR3GBlWrJIx+Bw==", "integrity": "sha512-MY0ugBDjFb5Xt8v2HY7MKcRGqw/3gTpMlLXId2EwQvYJoC8sP7nnXjAxcBtTB50KTZhO0SbzsFimaZ7pSdApwA==",
"requires": { "requires": {
"@virtuoso.dev/urx": "^0.2.8" "@virtuoso.dev/urx": "^0.2.13"
} }
}, },
"@virtuoso.dev/urx": { "@virtuoso.dev/urx": {
"version": "0.2.8", "version": "0.2.13",
"resolved": "https://registry.npmjs.org/@virtuoso.dev/urx/-/urx-0.2.8.tgz", "resolved": "https://registry.npmjs.org/@virtuoso.dev/urx/-/urx-0.2.13.tgz",
"integrity": "sha512-40uiTn5z1J/7at9Ao/aesyFhE7sS1cpb2MrbP/JSS6dcLMsQJcAZGedCY3bKkyjANH9DX/ciUpkpmT3TXmUBOQ==" "integrity": "sha512-iirJNv92A1ZWxoOHHDYW/1KPoi83939o83iUBQHIim0i3tMeSKEh+bxhJdTHQ86Mr4uXx9xGUTq69cp52ZP8Xw=="
}, },
"@vitejs/plugin-react": { "@vitejs/plugin-react": {
"version": "1.0.4", "version": "1.0.4",
@ -9200,12 +9201,12 @@
} }
}, },
"react-virtuoso": { "react-virtuoso": {
"version": "2.2.8", "version": "2.13.1",
"resolved": "https://registry.npmjs.org/react-virtuoso/-/react-virtuoso-2.2.8.tgz", "resolved": "https://registry.npmjs.org/react-virtuoso/-/react-virtuoso-2.13.1.tgz",
"integrity": "sha512-DArJ29ZrhdygdLKdXs/GB3VNDxaAF7YjdgIjlWPuK6UsV848hNmtw9px3LPdcQBErxaz7XCtHNfc7yInX+/M7g==", "integrity": "sha512-Wek6hYPYU7W1r9fGEF1/sMrFOH8VV8PW64fCRxx9OSSlq5q+5Cp0uf+7eP0jFWqpZtC2DrqFoj+seZXjkiyHrA==",
"requires": { "requires": {
"@virtuoso.dev/react-urx": "^0.2.8", "@virtuoso.dev/react-urx": "^0.2.12",
"@virtuoso.dev/urx": "^0.2.8" "@virtuoso.dev/urx": "^0.2.12"
} }
}, },
"redux": { "redux": {

View File

@ -32,7 +32,7 @@
"react-player": "^2.9.0", "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.13.1",
"socket.io-client": "^4.4.1", "socket.io-client": "^4.4.1",
"validator": "^13.6.0", "validator": "^13.6.0",
"yup": "^0.32.9" "yup": "^0.32.9"

View File

@ -18,6 +18,19 @@ import { SocketContext } from './contexts';
import { HistoryMessageType, UserOnlineListType, UserSeesionType } from './types'; import { HistoryMessageType, UserOnlineListType, UserSeesionType } from './types';
import { useToast } from '@chakra-ui/react'; import { useToast } from '@chakra-ui/react';
/**
* Virtuoso's resize observer can this error,
* which is caught by DnD and aborts dragging.
*/
window.addEventListener('error', e => {
if (
e.message === 'ResizeObserver loop completed with undelivered notifications.' ||
e.message === 'ResizeObserver loop limit exceeded'
) {
e.stopImmediatePropagation();
}
});
const App = () => { const App = () => {
const { const {
userStore, userStore,

View File

@ -58,9 +58,10 @@ const CourseForm: React.FC<CourseFormPropsType> = ({ onSubmit, buttonLabel, isLo
*/ */
const loadUsersOptions = async (): Promise<OptionType[] | undefined> => { const loadUsersOptions = async (): Promise<OptionType[] | undefined> => {
try { try {
const users = await UserServiceInstance.fetchAll({ limit: 12, role: UserRoleEnum.TEACHER }); const teachers = await UserServiceInstance.fetchAll({ role: UserRoleEnum.TEACHER });
const admins = await UserServiceInstance.fetchAll({ role: UserRoleEnum.ADMIN });
return users.data.map(user => ({ return teachers.data.concat(admins.data).map(user => ({
label: user.fullname, label: user.fullname,
value: user.id, value: user.id,
})); }));

View File

@ -27,6 +27,7 @@ const CreateLessonForm: React.FC = () => {
description: data.description, description: data.description,
duration: data.duration, duration: data.duration,
video: data.video, video: data.video,
linked_video_url: data.linked_video_url,
materials: Array.from(data.materials || []), materials: Array.from(data.materials || []),
course_id, course_id,
}); });

View File

@ -27,6 +27,7 @@ const EditLessonForm: React.FC<EditLessonFormPropsType> = ({ lesson }) => {
title: lesson.title, title: lesson.title,
description: lesson.description, description: lesson.description,
duration: lesson.duration, duration: lesson.duration,
linked_video_url: lesson.linked_video_url || undefined,
video: undefined, video: undefined,
materials: undefined, materials: undefined,
}, },
@ -41,6 +42,7 @@ const EditLessonForm: React.FC<EditLessonFormPropsType> = ({ lesson }) => {
description: data.description, description: data.description,
video: data.video, video: data.video,
duration: data.duration, duration: data.duration,
linked_video_url: data.linked_video_url,
materials: data.materials?.length !== 0 ? Array.from(data.materials || []) : null, materials: data.materials?.length !== 0 ? Array.from(data.materials || []) : null,
}); });

View File

@ -1,4 +1,4 @@
import React from 'react'; import React, { useState } from 'react';
import { import {
FormControl, FormControl,
FormHelperText, FormHelperText,
@ -26,6 +26,7 @@ export type InputFields = {
description: string; description: string;
duration: string; duration: string;
video: File; video: File;
linked_video_url?: string | undefined;
materials?: FileList | undefined; materials?: FileList | undefined;
}; };
@ -53,6 +54,8 @@ const LessonForm: React.FC<LessonFormPropsType> = ({
formState: { errors, isDirty }, formState: { errors, isDirty },
} = reactHookForm; } = reactHookForm;
const [isLinkedVideoUrl, setIsLinkedVideoUrl] = useState<boolean>(false);
const watchDescription = watch('description'); const watchDescription = watch('description');
return ( return (
@ -85,7 +88,19 @@ const LessonForm: React.FC<LessonFormPropsType> = ({
</Flex> </Flex>
</FormControl> </FormControl>
<FormControl isRequired id='video'> {isLinkedVideoUrl ? (
<FormControl id='linked_video_url'>
<FormLabel>Video URL</FormLabel>
<Input placeholder='Link to video source. (YouTube, Vimeo, .mp4)' {...register('linked_video_url')} />
<Text as='small' color='red.500'>
{errors.linked_video_url?.message}
</Text>
<FormHelperText textDecoration={'underline'} cursor='pointer' onClick={() => setIsLinkedVideoUrl(false)}>
choose from files
</FormHelperText>
</FormControl>
) : (
<FormControl id='video'>
<FormLabel>Video</FormLabel> <FormLabel>Video</FormLabel>
<Controller <Controller
control={control} control={control}
@ -94,6 +109,7 @@ const LessonForm: React.FC<LessonFormPropsType> = ({
<VideoUploader <VideoUploader
onChange={file => onChange(file)} onChange={file => onChange(file)}
file={file} file={file}
setIsLinkedVideoUrl={setIsLinkedVideoUrl}
preloadedVideoUrl={preloadedVideo && constants.BACKEND_URL + preloadedVideo.url} preloadedVideoUrl={preloadedVideo && constants.BACKEND_URL + preloadedVideo.url}
/> />
)} )}
@ -108,6 +124,7 @@ const LessonForm: React.FC<LessonFormPropsType> = ({
.join(', ')} .join(', ')}
</FormHelperText> </FormHelperText>
</FormControl> </FormControl>
)}
<FormControl isRequired id='duration'> <FormControl isRequired id='duration'>
<FormLabel>Video duration</FormLabel> <FormLabel>Video duration</FormLabel>

View File

@ -18,7 +18,8 @@ const CreateLessonFormSchema = yup
.test('fileSize', `File size is too large, maximum size is ${helpers.transformBytes(VIDEO_FILE_SIZE)}`, value => .test('fileSize', `File size is too large, maximum size is ${helpers.transformBytes(VIDEO_FILE_SIZE)}`, value =>
!value ? true : value.size <= VIDEO_FILE_SIZE !value ? true : value.size <= VIDEO_FILE_SIZE
) )
.required('Video field name is required'), .optional(),
linked_video_url: yup.string().matches(new RegExp('^(http|https|ftp)://'), 'URL is incorrect'),
materials: yup materials: yup
.mixed<File[]>() .mixed<File[]>()
.test( .test(

View File

@ -41,7 +41,7 @@ const Select: React.FC<ISelectPropsType> = props => {
> >
{options?.find(option => option.value === value)?.label || props?.placeholder || 'Select Option'} {options?.find(option => option.value === value)?.label || props?.placeholder || 'Select Option'}
</MenuButton> </MenuButton>
<MenuList> <MenuList maxH='175px' m='0' p='0' overflowY={'scroll'}>
{options && options.length !== 0 ? ( {options && options.length !== 0 ? (
options.map(option => ( options.map(option => (
<MenuItem <MenuItem
@ -49,6 +49,9 @@ const Select: React.FC<ISelectPropsType> = props => {
onClick={() => { onClick={() => {
props.onChange(option.value); props.onChange(option.value);
}} }}
pb='0'
pt='0'
minH='35px'
> >
{option.label} {option.label}
</MenuItem> </MenuItem>

View File

@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react';
import { Flex, Box, Icon, Text, Button, Input, AspectRatio, useColorModeValue } from '@chakra-ui/react'; import { Flex, Box, Icon, Text, Button, Input, AspectRatio, useColorModeValue } from '@chakra-ui/react';
import { MdUpload } from 'react-icons/md'; import { MdLink, MdUpload } from 'react-icons/md';
import { VideoSupportedFormatsEnum } from '@educt/enums'; import { VideoSupportedFormatsEnum } from '@educt/enums';
import { LessonVideoType } from '@educt/types'; import { LessonVideoType } from '@educt/types';
@ -8,9 +8,15 @@ type VideoUploaderPropsType = {
onChange: (file: File) => void; onChange: (file: File) => void;
file?: File | LessonVideoType | undefined; file?: File | LessonVideoType | undefined;
preloadedVideoUrl?: string | undefined; preloadedVideoUrl?: string | undefined;
setIsLinkedVideoUrl?: Dispatch<SetStateAction<boolean>>;
}; };
const VideoUploader: React.FC<VideoUploaderPropsType> = ({ onChange, file, preloadedVideoUrl }) => { const VideoUploader: React.FC<VideoUploaderPropsType> = ({
onChange,
file,
preloadedVideoUrl,
setIsLinkedVideoUrl,
}) => {
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
const [videoObjectUrl, setVideoObjectUrl] = useState<string | undefined>(undefined); const [videoObjectUrl, setVideoObjectUrl] = useState<string | undefined>(undefined);
@ -42,11 +48,23 @@ const VideoUploader: React.FC<VideoUploaderPropsType> = ({ onChange, file, prelo
}; };
const ChangeVideoButton = () => ( const ChangeVideoButton = () => (
<Button onClick={onSelectFileHandler} leftIcon={<MdUpload />} variant={'outline'} size='sm' mt='2' float='right'> <Button mr='1' onClick={onSelectFileHandler} leftIcon={<MdUpload />} variant={'outline'} size='sm' mt='2'>
Change video Change video
</Button> </Button>
); );
const ChangeUrlVideoButton = () => (
<Button
onClick={() => setIsLinkedVideoUrl && setIsLinkedVideoUrl(true)}
leftIcon={<MdLink />}
variant={'outline'}
size='sm'
mt='2'
>
From URL
</Button>
);
useEffect(() => { useEffect(() => {
if (videoRef.current) { if (videoRef.current) {
videoRef.current.load(); videoRef.current.load();
@ -91,9 +109,14 @@ const VideoUploader: React.FC<VideoUploaderPropsType> = ({ onChange, file, prelo
</Text> </Text>
</Text> </Text>
</Box> </Box>
<Button onClick={onSelectFileHandler} mt='6'> <Flex mt='4'>
<Button size='sm' onClick={onSelectFileHandler} mr='2'>
Select file Select file
</Button> </Button>
<Button size='sm' onClick={() => setIsLinkedVideoUrl && setIsLinkedVideoUrl(true)}>
From URL
</Button>
</Flex>
</Flex> </Flex>
) : ( ) : (
<Box> <Box>
@ -102,7 +125,10 @@ const VideoUploader: React.FC<VideoUploaderPropsType> = ({ onChange, file, prelo
<source src={preloadedVideoUrl} /> <source src={preloadedVideoUrl} />
</video> </video>
</AspectRatio> </AspectRatio>
<Flex justifyContent={'flex-end'}>
<ChangeVideoButton /> <ChangeVideoButton />
<ChangeUrlVideoButton />
</Flex>
</Box> </Box>
)} )}
<Input <Input

View File

@ -57,6 +57,7 @@ export interface ILesson {
progress: LessonProgress; progress: LessonProgress;
content: ILessonContent; content: ILessonContent;
video?: LessonVideoType | undefined | null; video?: LessonVideoType | undefined | null;
linked_video_url?: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }

View File

@ -138,6 +138,8 @@ const LessonPage: React.FC<IPageProps> = () => {
if (me === null) return <LoadingPage />; if (me === null) return <LoadingPage />;
const videoURL = lesson.video ? constants.BACKEND_URL + lesson.video.url : lesson.linked_video_url;
return ( return (
<Page> <Page>
<Helmet> <Helmet>
@ -153,13 +155,13 @@ const LessonPage: React.FC<IPageProps> = () => {
<Grid templateColumns={{ base: '1fr', xl: '3fr 1fr' }}> <Grid templateColumns={{ base: '1fr', xl: '3fr 1fr' }}>
<GridItem> <GridItem>
<Box overflowY='hidden'> <Box overflowY='hidden'>
{lesson.video ? ( {lesson.video || lesson.linked_video_url ? (
<ReactPlayer <ReactPlayer
style={{ backgroundColor: '#000' }} style={{ backgroundColor: '#000' }}
width={'100%'} width={'100%'}
height={'547px'} height={'547px'}
controls controls
url={constants.BACKEND_URL + lesson.video.url} url={videoURL || ''}
/> />
) : ( ) : (
<Flex h='547px' justifyContent={'center'} alignItems={'center'}> <Flex h='547px' justifyContent={'center'} alignItems={'center'}>

View File

@ -207,6 +207,7 @@ export type CreateLessonParamsType = {
duration: string; duration: string;
course_id: string; course_id: string;
video: File; video: File;
linked_video_url?: string | undefined;
materials?: File[] | undefined; materials?: File[] | undefined;
}; };
@ -216,6 +217,7 @@ export type UpdateLessonParamsType = {
duration?: string | undefined; duration?: string | undefined;
course_id?: string | undefined; course_id?: string | undefined;
video?: File | undefined; video?: File | undefined;
linked_video_url?: string | undefined;
materials?: File[] | undefined | null; materials?: File[] | undefined | null;
}; };