Compare commits

..

No commits in common. "develop" and "v1.0.0" have entirely different histories.

31 changed files with 154 additions and 5998 deletions

View File

@ -1,15 +0,0 @@
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,4 +4,3 @@ stats.html
dist dist
dist-ssr dist-ssr
*.local *.local
.env

View File

@ -1,23 +0,0 @@
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 @@
# educt-client ## Edcut client
_This service is part of [Educt](https://github.com/sergeyyarkov/educt) which contains frontend application._ ...

2
constants.ts Normal file
View File

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

View File

@ -1,4 +0,0 @@
[[redirects]]
from = "/*"
to = "/index.html"
status = 200

View File

@ -1,8 +0,0 @@
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.1.2", "version": "1.0.0",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "tsc && 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.13.1", "react-virtuoso": "^2.2.8",
"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,19 +18,6 @@ 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 '@educt/constants'; import * as constants from '../constants';
const apiConfig: AxiosRequestConfig = { const apiConfig: AxiosRequestConfig = {
baseURL: `${constants.BACKEND_URL}/`, baseURL: `${constants.BACKEND_URL}/api/`,
responseType: 'json', responseType: 'json',
withCredentials: true, withCredentials: true,
}; };

View File

@ -25,7 +25,6 @@ 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;
@ -58,10 +57,9 @@ const CourseForm: React.FC<CourseFormPropsType> = ({ onSubmit, buttonLabel, isLo
*/ */
const loadUsersOptions = async (): Promise<OptionType[] | undefined> => { const loadUsersOptions = async (): Promise<OptionType[] | undefined> => {
try { try {
const teachers = await UserServiceInstance.fetchAll({ role: UserRoleEnum.TEACHER }); const users = await UserServiceInstance.fetchAll({ limit: 12, role: UserRoleEnum.TEACHER });
const admins = await UserServiceInstance.fetchAll({ role: UserRoleEnum.ADMIN });
return teachers.data.concat(admins.data).map(user => ({ return users.data.map(user => ({
label: user.fullname, label: user.fullname,
value: user.id, value: user.id,
})); }));
@ -115,11 +113,8 @@ 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 <Text as='small' color={!!errors.description || watchDescription?.length > 250 ? 'red.500' : 'gray.500'}>
as='small' {watchDescription?.length || 0}/250
color={!!errors.description || watchDescription?.length > DESCRIPTION_MAX_LENGTH ? 'red.500' : 'gray.500'}
>
{watchDescription?.length || 0}/{DESCRIPTION_MAX_LENGTH}
</Text> </Text>
</Flex> </Flex>
</FormControl> </FormControl>
@ -135,12 +130,10 @@ const CourseForm: React.FC<CourseFormPropsType> = ({ onSubmit, buttonLabel, isLo
<Text <Text
as='small' as='small'
color={ color={
!!errors.education_description || watchEducationDescription.length > EDUCATION_DESCRIPTION_MAX_LENGTH !!errors.education_description || watchEducationDescription.length > 250 ? 'red.500' : 'gray.500'
? 'red.500'
: 'gray.500'
} }
> >
{watchEducationDescription.length || 0}/{EDUCATION_DESCRIPTION_MAX_LENGTH} {watchEducationDescription.length || 0}/250
</Text> </Text>
)} )}
</Flex> </Flex>

View File

@ -1,20 +1,15 @@
import yup from '@educt/schema'; import yup from '@educt/schema';
export const FILE_SIZE = 1000 * 1024; const FILE_SIZE = 1000 * 1024;
export const SUPPORTED_FORMATS = ['image/jpg', 'image/jpeg', 'image/png', 'image/webp']; 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(DESCRIPTION_MAX_LENGTH, 'Description must be at most 250 characters'), .max(250, 'Description must be at most 250 characters'),
education_description: yup education_description: yup.string().max(250, 'This section must be at most 250 characters').nullable(),
.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,7 +27,6 @@ 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,7 +27,6 @@ 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,
}, },
@ -42,7 +41,6 @@ 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, { useEffect, useState } from 'react'; import React from 'react';
import { import {
FormControl, FormControl,
FormHelperText, FormHelperText,
@ -13,20 +13,17 @@ 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;
}; };
@ -50,21 +47,9 @@ 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'}>
@ -86,52 +71,28 @@ 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>
{isLinkedVideoUrl ? ( <FormControl isRequired id='video'>
<FormControl id='linked_video_url'> <FormLabel>Video</FormLabel>
<FormLabel>Video URL</FormLabel> <Controller
<Input placeholder='Link to video source. (YouTube, Vimeo, .mp4)' {...register('linked_video_url')} /> control={control}
<Text as='small' color='red.500'> name='video'
{errors.linked_video_url?.message} render={({ field: { onChange, value: file } }) => (
</Text> <VideoUploader onChange={file => onChange(file)} file={file} preloadedVideoUrl={preloadedVideo?.url} />
<FormHelperText textDecoration={'underline'} cursor='pointer' onClick={() => setIsLinkedVideoUrl(false)}> )}
choose from files />
</FormHelperText> <Text as='small' color='red.500'>
</FormControl> {errors.video?.message}
) : ( </Text>
<FormControl id='video'> <FormHelperText>
<FormLabel>Video</FormLabel> Supported formats:{' '}
<Controller {Object.keys(VideoSupportedFormatsEnum)
control={control} .map(ext => `${ext.toLowerCase()}`)
name='video' .join(', ')}
render={({ field: { onChange, value: file } }) => ( </FormHelperText>
<VideoUploader </FormControl>
onChange={file => onChange(file)}
file={file}
setIsLinkedVideoUrl={setIsLinkedVideoUrl}
preloadedVideoUrl={preloadedVideo && constants.BACKEND_URL + preloadedVideo.url}
/>
)}
/>
<Text as='small' color='red.500'>
{errors.video?.message}
</Text>
<FormHelperText>
Supported formats:{' '}
{Object.keys(VideoSupportedFormatsEnum)
.map(ext => `${ext.toLowerCase()}`)
.join(', ')}
</FormHelperText>
</FormControl>
)}
<FormControl isRequired id='duration'> <FormControl isRequired id='duration'>
<FormLabel>Video duration</FormLabel> <FormLabel>Video duration</FormLabel>

View File

@ -1,9 +1,8 @@
import * as helpers from '@educt/helpers'; import * as helpers from '@educt/helpers';
import yup from '@educt/schema'; import yup from '@educt/schema';
export const VIDEO_FILE_SIZE = 5000 * 1024 * 1024; const VIDEO_FILE_SIZE = 5000 * 1024 * 1024;
export const MATERIAL_FILE_SIZE = 100000 * 1024; const MATERIAL_FILE_SIZE = 100000 * 1024;
export const DESCRIPTION_MAX_LENGTH = 250;
const CreateLessonFormSchema = yup const CreateLessonFormSchema = yup
.object({ .object({
@ -11,15 +10,14 @@ const CreateLessonFormSchema = yup
description: yup description: yup
.string() .string()
.required('Description field name is required') .required('Description field name is required')
.max(DESCRIPTION_MAX_LENGTH, 'Description must be at most 250 characters'), .max(250, '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
) )
.optional(), .required('Video field name is required'),
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 maxH='175px' m='0' p='0' overflowY={'scroll'}> <MenuList>
{options && options.length !== 0 ? ( {options && options.length !== 0 ? (
options.map(option => ( options.map(option => (
<MenuItem <MenuItem
@ -49,9 +49,6 @@ 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 | null; isOnline?: boolean | undefined;
} }
export interface IChatButtonProps { export interface IChatButtonProps {

View File

@ -1,6 +1,6 @@
import React, { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react'; import React, { 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 { MdLink, MdUpload } from 'react-icons/md'; import { 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,15 +8,9 @@ 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> = ({ const VideoUploader: React.FC<VideoUploaderPropsType> = ({ onChange, file, preloadedVideoUrl }) => {
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);
@ -48,23 +42,11 @@ const VideoUploader: React.FC<VideoUploaderPropsType> = ({
}; };
const ChangeVideoButton = () => ( const ChangeVideoButton = () => (
<Button mr='1' onClick={onSelectFileHandler} leftIcon={<MdUpload />} variant={'outline'} size='sm' mt='2'> <Button onClick={onSelectFileHandler} leftIcon={<MdUpload />} variant={'outline'} size='sm' mt='2' float='right'>
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();
@ -109,14 +91,9 @@ const VideoUploader: React.FC<VideoUploaderPropsType> = ({
</Text> </Text>
</Text> </Text>
</Box> </Box>
<Flex mt='4'> <Button onClick={onSelectFileHandler} mt='6'>
<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>
@ -125,10 +102,7 @@ const VideoUploader: React.FC<VideoUploaderPropsType> = ({
<source src={preloadedVideoUrl} /> <source src={preloadedVideoUrl} />
</video> </video>
</AspectRatio> </AspectRatio>
<Flex justifyContent={'flex-end'}> <ChangeVideoButton />
<ChangeVideoButton />
<ChangeUrlVideoButton />
</Flex>
</Box> </Box>
)} )}
<Input <Input

View File

@ -1,2 +0,0 @@
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
View File

@ -1,10 +0,0 @@
/// <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,7 +57,6 @@ 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,7 +1,6 @@
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';
@ -46,7 +45,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 = () => course.lessons[0] && history.push(`/lesson/${course.lessons[0].id}`); const handleWatchCourse = () => history.push(`/lesson/${course.lessons[0].id}`);
const handleEditCourse = () => history.push(`/courses/edit/${course.id}`); const handleEditCourse = () => history.push(`/courses/edit/${course.id}`);
@ -74,7 +73,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 || course.lessons[0] === undefined} isDisabled={!isCourseAvailable}
onClick={handleWatchCourse} onClick={handleWatchCourse}
leftIcon={<MdPlayCircleOutline />} leftIcon={<MdPlayCircleOutline />}
size='sm' size='sm'
@ -84,10 +83,7 @@ const CoursePage: React.FC<IPageProps> = () => {
</Button> </Button>
</Box> </Box>
</Flex> </Flex>
<CourseBackgroundImage <CourseBackgroundImage bg={course.color?.hex} src={course.image?.url} />
bg={course.color?.hex}
src={course.image ? `${constants.BACKEND_URL + course.image.url}` : undefined}
/>
</Box> </Box>
<Box mt='4'> <Box mt='4'>
<Tabs> <Tabs>
@ -133,7 +129,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' textAlign={'justify'}> <Text fontSize={'sm'} mt='2'>
{course.education_description} {course.education_description}
</Text> </Text>
</Box> </Box>

View File

@ -7,7 +7,6 @@ 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
@ -103,9 +102,8 @@ const CourseItem: React.FC<CourseItemPropsType> = ({ course, onDelete, onSetStat
<Link <Link
to={`/course/${course.id}`} to={`/course/${course.id}`}
as={ReactRouterLink} as={ReactRouterLink}
display='flex' display='block'
flexDir={'column'} h='full'
h='100%'
textDecoration='none' textDecoration='none'
_visited={{ textDecoration: 'none' }} _visited={{ textDecoration: 'none' }}
_hover={{ textDecoration: 'none' }} _hover={{ textDecoration: 'none' }}
@ -130,11 +128,11 @@ const CourseItem: React.FC<CourseItemPropsType> = ({ course, onDelete, onSetStat
w='100%' w='100%'
borderTopLeftRadius='lg' borderTopLeftRadius='lg'
borderTopRightRadius='lg' borderTopRightRadius='lg'
src={course.image ? `${constants.BACKEND_URL + course.image.url}` : undefined} src={course.image.url}
/> />
)} )}
</Box> </Box>
<Flex flexDir={'column'} p='0 10px' mt='10px' pb='20px' h='100%'> <Box p='0 10px' mt='10px' pb='20px'>
<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} />}
@ -142,10 +140,8 @@ 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' mb='4'> <Text mt='2'>{course.description}</Text>
{course.description} <Flex mt='20px' flexWrap='wrap'>
</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>
@ -159,7 +155,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>
</Flex> </Box>
</Link> </Link>
</Box> </Box>
); );

View File

@ -69,7 +69,6 @@ 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,6 +1,5 @@
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';
@ -84,7 +83,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 || lesson.linked_video_url) && (await fetchLessonProgress(lesson.id)); lesson.video && (await fetchLessonProgress(lesson.id));
})(); })();
} }
}, [lesson]); }, [lesson]);
@ -138,8 +137,6 @@ 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>
@ -154,17 +151,11 @@ 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 overflowY='hidden'> <Box>
{lesson.video || lesson.linked_video_url ? ( {lesson.video ? (
<ReactPlayer <ReactPlayer width={'100%'} height={'100%'} controls url={lesson.video?.url} />
style={{ backgroundColor: '#000' }}
width={'100%'}
height={'547px'}
controls
url={videoURL || ''}
/>
) : ( ) : (
<Flex h='547px' justifyContent={'center'} alignItems={'center'}> <Flex h='500px' 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>
@ -200,7 +191,7 @@ const LessonPage: React.FC<IPageProps> = () => {
</GridItem> </GridItem>
<GridItem minWidth={'250px'}> <GridItem minWidth={'250px'}>
<Box> <Box>
<Stack maxH={'547px'} minH={'547px'} overflowY={'scroll'} spacing={'0'}> <Stack maxH={'500px'} 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

@ -26,7 +26,6 @@ import { observer } from 'mobx-react';
* Hooks * Hooks
*/ */
import { useFetchCourses, useFetchStat } from '@educt/hooks/queries'; import { useFetchCourses, useFetchStat } from '@educt/hooks/queries';
import { CourseStatusEnum } from '@educt/enums';
/** /**
* Main page * Main page
@ -36,7 +35,7 @@ const MainPage: React.FC<IPageProps> = () => {
userStore: { me }, userStore: { me },
onlineStore, onlineStore,
} = useRootStore(); } = useRootStore();
const { data: courses } = useFetchCourses({ limit: 3, status: CourseStatusEnum.PUBLISHED }); const { data: courses } = useFetchCourses({ limit: 3 });
const { data: stat } = useFetchStat(); const { data: stat } = useFetchStat();
if (me === null || courses === null || stat === null) return <LoadingPage />; if (me === null || courses === null || stat === null) return <LoadingPage />;

View File

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

View File

@ -207,7 +207,6 @@ 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;
}; };
@ -217,7 +216,6 @@ 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,4 +1,5 @@
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';
@ -19,7 +20,20 @@ export default defineConfig({
publicDir: 'public', publicDir: 'public',
plugins: [react()], plugins: [react()],
server: { server: {
port: 5000, proxy: {
'/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') }],