Merge branch 'release/1.1.0'

This commit is contained in:
Sergey Yarkov 2022-06-12 20:42:32 +03:00
commit 65fa6f23b6
29 changed files with 5992 additions and 153 deletions

15
.dockerignore Normal file
View File

@ -0,0 +1,15 @@
node_modules
dist
dist-ssr
coverage
stats.html
Dockerfile*
docker-compose*
.dockerignore
*.md
*.local
.git
.gitignore
.vscode
.DS_STORE
.env

1
.gitignore vendored
View File

@ -4,3 +4,4 @@ stats.html
dist dist
dist-ssr dist-ssr
*.local *.local
.env

23
Dockerfile Normal file
View File

@ -0,0 +1,23 @@
ARG NODE_IMAGE=node:14.17.3-alpine3.11
ARG NGINX_IMAGE=nginx:1.16.0-alpine
FROM ${NODE_IMAGE} as builder
WORKDIR /home/app
COPY package*.json ./
RUN npm install
COPY . .
# Get build arguments and set env variables
ARG API_URL
ARG WS_URL
ENV VITE_API_URL=${API_URL}
ENV VITE_WS_URL=${WS_URL}
RUN npm run build
FROM ${NGINX_IMAGE} as production
WORKDIR /usr/share/nginx/html
RUN rm /etc/nginx/conf.d/default.conf
COPY --from=builder /home/app/dist /usr/share/nginx/html
COPY ./nginx/default.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@ -1,3 +1,3 @@
## Edcut client # educt-client
... _This service is part of [Educt](https://github.com/sergeyyarkov/educt) which contains frontend application._

View File

@ -1,2 +0,0 @@
export const BACKEND_URL = 'http://localhost:3333';
export const WS_URL = 'ws://localhost:3333';

8
nginx/default.conf Normal file
View File

@ -0,0 +1,8 @@
server {
listen 5000;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html =404;
}
}

5811
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,9 +1,9 @@
{ {
"name": "educt-client", "name": "educt-client",
"version": "1.0.0", "version": "1.1.0",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "vite build",
"serve": "vite preview", "serve": "vite preview",
"format": "prettier --write \"src/**/*.{js,jsx,ts,tsx}\"", "format": "prettier --write \"src/**/*.{js,jsx,ts,tsx}\"",
"lint": "prettier --check \"src/**/*.{js,jsx,ts,tsx}\"" "lint": "prettier --check \"src/**/*.{js,jsx,ts,tsx}\""
@ -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

@ -1,8 +1,8 @@
import { AxiosRequestConfig } from 'axios'; import { AxiosRequestConfig } from 'axios';
import * as constants from '../constants'; import * as constants from '@educt/constants';
const apiConfig: AxiosRequestConfig = { const apiConfig: AxiosRequestConfig = {
baseURL: `${constants.BACKEND_URL}/api/`, baseURL: `${constants.BACKEND_URL}/`,
responseType: 'json', responseType: 'json',
withCredentials: true, withCredentials: true,
}; };

View File

@ -25,6 +25,7 @@ import { useErrorHandler } from 'react-error-boundary';
* Services * Services
*/ */
import { CategoryServiceInstance, UserServiceInstance } from '@educt/services'; import { CategoryServiceInstance, UserServiceInstance } from '@educt/services';
import { DESCRIPTION_MAX_LENGTH, EDUCATION_DESCRIPTION_MAX_LENGTH } from './CourseForm.validator';
export type InputFields = { export type InputFields = {
title: string; title: string;
@ -57,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,
})); }));
@ -113,8 +115,11 @@ const CourseForm: React.FC<CourseFormPropsType> = ({ onSubmit, buttonLabel, isLo
<Text as='small' color='red.500'> <Text as='small' color='red.500'>
{errors.description?.message} {errors.description?.message}
</Text> </Text>
<Text as='small' color={!!errors.description || watchDescription?.length > 250 ? 'red.500' : 'gray.500'}> <Text
{watchDescription?.length || 0}/250 as='small'
color={!!errors.description || watchDescription?.length > DESCRIPTION_MAX_LENGTH ? 'red.500' : 'gray.500'}
>
{watchDescription?.length || 0}/{DESCRIPTION_MAX_LENGTH}
</Text> </Text>
</Flex> </Flex>
</FormControl> </FormControl>
@ -130,10 +135,12 @@ const CourseForm: React.FC<CourseFormPropsType> = ({ onSubmit, buttonLabel, isLo
<Text <Text
as='small' as='small'
color={ color={
!!errors.education_description || watchEducationDescription.length > 250 ? 'red.500' : 'gray.500' !!errors.education_description || watchEducationDescription.length > EDUCATION_DESCRIPTION_MAX_LENGTH
? 'red.500'
: 'gray.500'
} }
> >
{watchEducationDescription.length || 0}/250 {watchEducationDescription.length || 0}/{EDUCATION_DESCRIPTION_MAX_LENGTH}
</Text> </Text>
)} )}
</Flex> </Flex>

View File

@ -1,15 +1,20 @@
import yup from '@educt/schema'; import yup from '@educt/schema';
const FILE_SIZE = 1000 * 1024; export const FILE_SIZE = 1000 * 1024;
const SUPPORTED_FORMATS = ['image/jpg', 'image/jpeg', 'image/png', 'image/webp']; export const SUPPORTED_FORMATS = ['image/jpg', 'image/jpeg', 'image/png', 'image/webp'];
export const DESCRIPTION_MAX_LENGTH = 250;
export const EDUCATION_DESCRIPTION_MAX_LENGTH = 250;
const CreateCourseSchema = yup.object().shape({ const CreateCourseSchema = yup.object().shape({
title: yup.string().required('Title field name is required').max(90, 'Title must be at most 90 characters'), title: yup.string().required('Title field name is required').max(90, 'Title must be at most 90 characters'),
description: yup description: yup
.string() .string()
.required('Description field name is required') .required('Description field name is required')
.max(250, 'Description must be at most 250 characters'), .max(DESCRIPTION_MAX_LENGTH, 'Description must be at most 250 characters'),
education_description: yup.string().max(250, 'This section must be at most 250 characters').nullable(), education_description: yup
.string()
.max(EDUCATION_DESCRIPTION_MAX_LENGTH, 'This section must be at most 250 characters')
.nullable(),
image: yup image: yup
.mixed() .mixed()
.test('fileSize', 'File size is too large', (value: File) => { .test('fileSize', 'File size is too large', (value: File) => {

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, { useEffect, useState } from 'react';
import { import {
FormControl, FormControl,
FormHelperText, FormHelperText,
@ -13,17 +13,20 @@ import {
Flex, Flex,
Divider, Divider,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import * as constants from '@educt/constants';
import { Controller, UseFormReturn } from 'react-hook-form'; import { Controller, UseFormReturn } from 'react-hook-form';
import VideoUploader from '@educt/components/VideoUploader'; import VideoUploader from '@educt/components/VideoUploader';
import FilesUploader from '@educt/components/FilesUploader'; import FilesUploader from '@educt/components/FilesUploader';
import { FileSupportedFormatsEnum, VideoSupportedFormatsEnum } from '@educt/enums'; import { FileSupportedFormatsEnum, VideoSupportedFormatsEnum } from '@educt/enums';
import type { AttachmentFileType, LessonVideoType } from '@educt/types'; import type { AttachmentFileType, LessonVideoType } from '@educt/types';
import { DESCRIPTION_MAX_LENGTH } from './LessonForm.validator';
export type InputFields = { export type InputFields = {
title: string; title: string;
description: string; description: string;
duration: string; duration: string;
video: File; video: File;
linked_video_url?: string | undefined;
materials?: FileList | undefined; materials?: FileList | undefined;
}; };
@ -47,9 +50,21 @@ const LessonForm: React.FC<LessonFormPropsType> = ({
const { const {
control, control,
register, register,
watch,
formState: { errors, isDirty }, formState: { errors, isDirty },
unregister,
} = reactHookForm; } = reactHookForm;
const [isLinkedVideoUrl, setIsLinkedVideoUrl] = useState<boolean>(false);
const watchDescription = watch('description');
useEffect(() => {
if (!isLinkedVideoUrl) {
unregister('linked_video_url');
}
}, [isLinkedVideoUrl]);
return ( return (
<form onSubmit={onSubmit}> <form onSubmit={onSubmit}>
<Stack spacing={'5'}> <Stack spacing={'5'}>
@ -71,16 +86,39 @@ const LessonForm: React.FC<LessonFormPropsType> = ({
<Text as='small' color='red.500'> <Text as='small' color='red.500'>
{errors.description?.message} {errors.description?.message}
</Text> </Text>
<Text
as='small'
color={!!errors.description || watchDescription?.length > DESCRIPTION_MAX_LENGTH ? 'red.500' : 'gray.500'}
>
{watchDescription?.length || 0}/{DESCRIPTION_MAX_LENGTH}
</Text>
</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}
name='video' name='video'
render={({ field: { onChange, value: file } }) => ( render={({ field: { onChange, value: file } }) => (
<VideoUploader onChange={file => onChange(file)} file={file} preloadedVideoUrl={preloadedVideo?.url} /> <VideoUploader
onChange={file => onChange(file)}
file={file}
setIsLinkedVideoUrl={setIsLinkedVideoUrl}
preloadedVideoUrl={preloadedVideo && constants.BACKEND_URL + preloadedVideo.url}
/>
)} )}
/> />
<Text as='small' color='red.500'> <Text as='small' color='red.500'>
@ -93,6 +131,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

@ -1,8 +1,9 @@
import * as helpers from '@educt/helpers'; import * as helpers from '@educt/helpers';
import yup from '@educt/schema'; import yup from '@educt/schema';
const VIDEO_FILE_SIZE = 5000 * 1024 * 1024; export const VIDEO_FILE_SIZE = 5000 * 1024 * 1024;
const MATERIAL_FILE_SIZE = 100000 * 1024; export const MATERIAL_FILE_SIZE = 100000 * 1024;
export const DESCRIPTION_MAX_LENGTH = 250;
const CreateLessonFormSchema = yup const CreateLessonFormSchema = yup
.object({ .object({
@ -10,14 +11,15 @@ const CreateLessonFormSchema = yup
description: yup description: yup
.string() .string()
.required('Description field name is required') .required('Description field name is required')
.max(250, 'Description must be at most 250 characters'), .max(DESCRIPTION_MAX_LENGTH, 'Description must be at most 250 characters'),
duration: yup.string().required('Duration field name is required'), duration: yup.string().required('Duration field name is required'),
video: yup video: yup
.mixed<File>() .mixed<File>()
.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

@ -3,7 +3,7 @@ import { ChatButton } from './ChatButton';
export interface IAvatarProps { export interface IAvatarProps {
name: string; name: string;
isOnline?: boolean | undefined; isOnline?: boolean | null;
} }
export interface IChatButtonProps { export interface IChatButtonProps {

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

2
src/constants.ts Normal file
View File

@ -0,0 +1,2 @@
export const BACKEND_URL = import.meta.env.VITE_API_URL || 'http://localhost:3333';
export const WS_URL = import.meta.env.VITE_WS_URL || 'ws://localhost:3333';

10
src/env.d.ts vendored Normal file
View File

@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL?: string | undefined;
readonly VITE_WS_URL?: string | undefined;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

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

@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { observer } from 'mobx-react'; import { observer } from 'mobx-react';
import * as helpres from '@educt/helpers'; import * as helpres from '@educt/helpers';
import * as constants from '@educt/constants';
import { Redirect } from 'react-router-dom'; import { Redirect } from 'react-router-dom';
import { Flex, Box, Text, Heading, Button, Grid, GridItem, Divider, AvatarGroup, Avatar } from '@chakra-ui/react'; import { Flex, Box, Text, Heading, Button, Grid, GridItem, Divider, AvatarGroup, Avatar } from '@chakra-ui/react';
import { MdPlayCircleOutline } from 'react-icons/md'; import { MdPlayCircleOutline } from 'react-icons/md';
@ -45,7 +46,7 @@ const CoursePage: React.FC<IPageProps> = () => {
const isEmptyLessons = course.lessons.length === 0; const isEmptyLessons = course.lessons.length === 0;
const isEmptyStudents = course.students.length === 0; const isEmptyStudents = course.students.length === 0;
const handleWatchCourse = () => history.push(`/lesson/${course.lessons[0].id}`); const handleWatchCourse = () => course.lessons[0] && history.push(`/lesson/${course.lessons[0].id}`);
const handleEditCourse = () => history.push(`/courses/edit/${course.id}`); const handleEditCourse = () => history.push(`/courses/edit/${course.id}`);
@ -73,7 +74,7 @@ const CoursePage: React.FC<IPageProps> = () => {
<Box> <Box>
{(me.isAdmin || me.isTeacher) && <EditButton onClick={handleEditCourse} mr='2' />} {(me.isAdmin || me.isTeacher) && <EditButton onClick={handleEditCourse} mr='2' />}
<Button <Button
isDisabled={!isCourseAvailable} isDisabled={!isCourseAvailable || course.lessons[0] === undefined}
onClick={handleWatchCourse} onClick={handleWatchCourse}
leftIcon={<MdPlayCircleOutline />} leftIcon={<MdPlayCircleOutline />}
size='sm' size='sm'
@ -83,7 +84,10 @@ const CoursePage: React.FC<IPageProps> = () => {
</Button> </Button>
</Box> </Box>
</Flex> </Flex>
<CourseBackgroundImage bg={course.color?.hex} src={course.image?.url} /> <CourseBackgroundImage
bg={course.color?.hex}
src={course.image ? `${constants.BACKEND_URL + course.image.url}` : undefined}
/>
</Box> </Box>
<Box mt='4'> <Box mt='4'>
<Tabs> <Tabs>
@ -129,7 +133,7 @@ const CoursePage: React.FC<IPageProps> = () => {
<Text fontSize={'2xl'} fontWeight={'medium'}> <Text fontSize={'2xl'} fontWeight={'medium'}>
What you&apos;ll learn. What you&apos;ll learn.
</Text> </Text>
<Text fontSize={'sm'} mt='2'> <Text fontSize={'sm'} mt='2' textAlign={'justify'}>
{course.education_description} {course.education_description}
</Text> </Text>
</Box> </Box>

View File

@ -7,6 +7,7 @@ import { Menu, MenuButton, MenuItem, MenuList } from '@chakra-ui/menu';
import { MdMoreHoriz, MdGroup, MdThumbUp, MdVideoLibrary } from 'react-icons/md'; import { MdMoreHoriz, MdGroup, MdThumbUp, MdVideoLibrary } from 'react-icons/md';
import { AiOutlineEye, AiOutlineEyeInvisible } from 'react-icons/ai'; import { AiOutlineEye, AiOutlineEyeInvisible } from 'react-icons/ai';
import { DeleteIcon, EditIcon } from '@chakra-ui/icons'; import { DeleteIcon, EditIcon } from '@chakra-ui/icons';
import * as constants from '@educt/constants';
/** /**
* Types * Types
@ -102,8 +103,9 @@ const CourseItem: React.FC<CourseItemPropsType> = ({ course, onDelete, onSetStat
<Link <Link
to={`/course/${course.id}`} to={`/course/${course.id}`}
as={ReactRouterLink} as={ReactRouterLink}
display='block' display='flex'
h='full' flexDir={'column'}
h='100%'
textDecoration='none' textDecoration='none'
_visited={{ textDecoration: 'none' }} _visited={{ textDecoration: 'none' }}
_hover={{ textDecoration: 'none' }} _hover={{ textDecoration: 'none' }}
@ -128,11 +130,11 @@ const CourseItem: React.FC<CourseItemPropsType> = ({ course, onDelete, onSetStat
w='100%' w='100%'
borderTopLeftRadius='lg' borderTopLeftRadius='lg'
borderTopRightRadius='lg' borderTopRightRadius='lg'
src={course.image.url} src={course.image ? `${constants.BACKEND_URL + course.image.url}` : undefined}
/> />
)} )}
</Box> </Box>
<Box p='0 10px' mt='10px' pb='20px'> <Flex flexDir={'column'} p='0 10px' mt='10px' pb='20px' h='100%'>
<Flex justifyContent='space-between'> <Flex justifyContent='space-between'>
<Badge colorScheme='blue'>{course.category.title}</Badge> <Badge colorScheme='blue'>{course.category.title}</Badge>
{(me.isAdmin || me.isTeacher) && <CourseStatusBadge status={course.status} />} {(me.isAdmin || me.isTeacher) && <CourseStatusBadge status={course.status} />}
@ -140,8 +142,10 @@ const CourseItem: React.FC<CourseItemPropsType> = ({ course, onDelete, onSetStat
<Heading as='h2' fontSize='xl' mt='5px'> <Heading as='h2' fontSize='xl' mt='5px'>
{course.title} {course.title}
</Heading> </Heading>
<Text mt='2'>{course.description}</Text> <Text mt='2' mb='4'>
<Flex mt='20px' flexWrap='wrap'> {course.description}
</Text>
<Flex mt='auto' flexWrap='wrap'>
<Flex alignItems='center' mr='1'> <Flex alignItems='center' mr='1'>
<Icon as={MdVideoLibrary} mr='2' /> <Icon as={MdVideoLibrary} mr='2' />
<Text mr='4'>{course.lessons_count} lessons</Text> <Text mr='4'>{course.lessons_count} lessons</Text>
@ -155,7 +159,7 @@ const CourseItem: React.FC<CourseItemPropsType> = ({ course, onDelete, onSetStat
<Text>{course.likes_count} likes</Text> <Text>{course.likes_count} likes</Text>
</Flex> </Flex>
</Flex> </Flex>
</Box> </Flex>
</Link> </Link>
</Box> </Box>
); );

View File

@ -69,6 +69,7 @@ const LessonList: React.FC<LessonListPropsType> = ({ course }) => {
const handleChangeOrder = async (ids: string[]) => { const handleChangeOrder = async (ids: string[]) => {
try { try {
if (ids.length <= 0) return;
const data = await LessonServiceInstance.saveOrder(ids); const data = await LessonServiceInstance.saveOrder(ids);
return data; return data;
} catch (error) { } catch (error) {

View File

@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import moment from 'moment'; import moment from 'moment';
import * as constants from '@educt/constants';
import * as helpres from '@educt/helpers'; import * as helpres from '@educt/helpers';
import { Link as ReactRouterLink, Redirect } from 'react-router-dom'; import { Link as ReactRouterLink, Redirect } from 'react-router-dom';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
@ -83,7 +84,7 @@ const LessonPage: React.FC<IPageProps> = () => {
if (lesson !== null) { if (lesson !== null) {
(async () => { (async () => {
await fetchCourseLessons(lesson.course_id); await fetchCourseLessons(lesson.course_id);
lesson.video && (await fetchLessonProgress(lesson.id)); (lesson.video || lesson.linked_video_url) && (await fetchLessonProgress(lesson.id));
})(); })();
} }
}, [lesson]); }, [lesson]);
@ -137,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>
@ -151,11 +154,17 @@ const LessonPage: React.FC<IPageProps> = () => {
<Page.Content> <Page.Content>
<Grid templateColumns={{ base: '1fr', xl: '3fr 1fr' }}> <Grid templateColumns={{ base: '1fr', xl: '3fr 1fr' }}>
<GridItem> <GridItem>
<Box> <Box overflowY='hidden'>
{lesson.video ? ( {lesson.video || lesson.linked_video_url ? (
<ReactPlayer width={'100%'} height={'100%'} controls url={lesson.video?.url} /> <ReactPlayer
style={{ backgroundColor: '#000' }}
width={'100%'}
height={'547px'}
controls
url={videoURL || ''}
/>
) : ( ) : (
<Flex h='500px' justifyContent={'center'} alignItems={'center'}> <Flex h='547px' justifyContent={'center'} alignItems={'center'}>
<Text color='gray.500' userSelect={'none'}> <Text color='gray.500' userSelect={'none'}>
Video has not been uploaded yet. Video has not been uploaded yet.
</Text> </Text>
@ -191,7 +200,7 @@ const LessonPage: React.FC<IPageProps> = () => {
</GridItem> </GridItem>
<GridItem minWidth={'250px'}> <GridItem minWidth={'250px'}>
<Box> <Box>
<Stack maxH={'500px'} overflowY={'scroll'} spacing={'0'}> <Stack maxH={'547px'} minH={'547px'} overflowY={'scroll'} spacing={'0'}>
{lessons.map((lesson, i) => ( {lessons.map((lesson, i) => (
<LinkBox <LinkBox
ref={lesson.id === id ? currentLessonRef : undefined} ref={lesson.id === id ? currentLessonRef : undefined}

View File

@ -1,5 +1,5 @@
import React, { useState, ReactNode } from 'react'; import React, { useState, ReactNode } from 'react';
import * as constants from '../../constants'; import * as constants from '@educt/constants';
import { import {
ChangeEmailPageContext, ChangeEmailPageContext,
CoursesPageContext, CoursesPageContext,

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;
}; };

View File

@ -1,5 +1,4 @@
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import * as constants from './constants';
import path from 'path'; import path from 'path';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import { visualizer } from 'rollup-plugin-visualizer'; import { visualizer } from 'rollup-plugin-visualizer';
@ -20,20 +19,7 @@ export default defineConfig({
publicDir: 'public', publicDir: 'public',
plugins: [react()], plugins: [react()],
server: { server: {
proxy: { port: 5000,
'/api': {
target: constants.BACKEND_URL,
changeOrigin: true,
},
'/uploads': {
target: constants.BACKEND_URL,
changeOrigin: true,
},
'/video': {
target: constants.BACKEND_URL,
changeOrigin: true,
},
},
}, },
resolve: { resolve: {
alias: [{ find: '@educt', replacement: path.resolve(__dirname, 'src') }], alias: [{ find: '@educt', replacement: path.resolve(__dirname, 'src') }],