mirror of
https://github.com/sergeyyarkov/educt-client.git
synced 2026-10-11 18:29:15 +03:00
Merge branch 'release/1.1.0'
This commit is contained in:
commit
65fa6f23b6
15
.dockerignore
Normal file
15
.dockerignore
Normal 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
1
.gitignore
vendored
@ -4,3 +4,4 @@ stats.html
|
|||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
.env
|
||||||
|
|||||||
23
Dockerfile
Normal file
23
Dockerfile
Normal 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;"]
|
||||||
@ -1,3 +1,3 @@
|
|||||||
## Edcut client
|
# educt-client
|
||||||
|
|
||||||
...
|
_This service is part of [Educt](https://github.com/sergeyyarkov/educt) which contains frontend application._
|
||||||
|
|||||||
@ -1,2 +0,0 @@
|
|||||||
export const BACKEND_URL = 'http://localhost:3333';
|
|
||||||
export const WS_URL = 'ws://localhost:3333';
|
|
||||||
8
nginx/default.conf
Normal file
8
nginx/default.conf
Normal 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
5811
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||||
|
|||||||
13
src/App.tsx
13
src/App.tsx
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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,28 +86,52 @@ 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 ? (
|
||||||
<FormLabel>Video</FormLabel>
|
<FormControl id='linked_video_url'>
|
||||||
<Controller
|
<FormLabel>Video URL</FormLabel>
|
||||||
control={control}
|
<Input placeholder='Link to video source. (YouTube, Vimeo, .mp4)' {...register('linked_video_url')} />
|
||||||
name='video'
|
<Text as='small' color='red.500'>
|
||||||
render={({ field: { onChange, value: file } }) => (
|
{errors.linked_video_url?.message}
|
||||||
<VideoUploader onChange={file => onChange(file)} file={file} preloadedVideoUrl={preloadedVideo?.url} />
|
</Text>
|
||||||
)}
|
<FormHelperText textDecoration={'underline'} cursor='pointer' onClick={() => setIsLinkedVideoUrl(false)}>
|
||||||
/>
|
choose from files
|
||||||
<Text as='small' color='red.500'>
|
</FormHelperText>
|
||||||
{errors.video?.message}
|
</FormControl>
|
||||||
</Text>
|
) : (
|
||||||
<FormHelperText>
|
<FormControl id='video'>
|
||||||
Supported formats:{' '}
|
<FormLabel>Video</FormLabel>
|
||||||
{Object.keys(VideoSupportedFormatsEnum)
|
<Controller
|
||||||
.map(ext => `${ext.toLowerCase()}`)
|
control={control}
|
||||||
.join(', ')}
|
name='video'
|
||||||
</FormHelperText>
|
render={({ field: { onChange, value: file } }) => (
|
||||||
</FormControl>
|
<VideoUploader
|
||||||
|
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>
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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'>
|
||||||
Select file
|
<Button size='sm' onClick={onSelectFileHandler} mr='2'>
|
||||||
</Button>
|
Select file
|
||||||
|
</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>
|
||||||
<ChangeVideoButton />
|
<Flex justifyContent={'flex-end'}>
|
||||||
|
<ChangeVideoButton />
|
||||||
|
<ChangeUrlVideoButton />
|
||||||
|
</Flex>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
2
src/constants.ts
Normal file
2
src/constants.ts
Normal 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
10
src/env.d.ts
vendored
Normal 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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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'll learn.
|
What you'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>
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
2
src/types/index.d.ts
vendored
2
src/types/index.d.ts
vendored
@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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') }],
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user