mirror of
https://github.com/sergeyyarkov/educt-client.git
synced 2026-10-11 10:19:22 +03:00
feat: refactor layout & added file select component
This commit is contained in:
parent
eb33113912
commit
ff88ad75a3
86
src/components/FileSelect/FileSelect.tsx
Normal file
86
src/components/FileSelect/FileSelect.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { Flex, Box, Text } from '@chakra-ui/layout';
|
||||
import { Button } from '@chakra-ui/button';
|
||||
import { MdAttachFile } from 'react-icons/md';
|
||||
import * as helpres from '@educt/helpers';
|
||||
|
||||
/**
|
||||
* Hooks
|
||||
*/
|
||||
import { useColorModeValue } from '@chakra-ui/color-mode';
|
||||
import { Input } from '@chakra-ui/input';
|
||||
|
||||
type FileSelectPropsType = {
|
||||
onChange: (file: File) => void;
|
||||
supportedFormats?: string[];
|
||||
};
|
||||
|
||||
const FileSelect: React.FC<FileSelectPropsType> = ({ onChange, supportedFormats }) => {
|
||||
const [selectedFile, setSelectedFile] = useState<File | undefined>(undefined);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
/**
|
||||
* Handle the file input selection
|
||||
*/
|
||||
const onSelectFileHandler = () => {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.click();
|
||||
}
|
||||
};
|
||||
|
||||
const onChangeFileHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
const { files } = e.target;
|
||||
|
||||
if (files && files.length !== 0) {
|
||||
const file = files[0];
|
||||
|
||||
onChange(file);
|
||||
setSelectedFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
backgroundColor={useColorModeValue('gray.50', 'gray.700')}
|
||||
p='15px 20px'
|
||||
lineHeight='20px'
|
||||
borderRadius='md'
|
||||
borderWidth='1px'
|
||||
>
|
||||
<Input type='file' ref={fileInputRef} onChange={onChangeFileHandler} display='none' />
|
||||
<Flex justifyContent='space-between' alignItems='center' sx={{ gap: '10px' }}>
|
||||
<Box>
|
||||
{selectedFile ? (
|
||||
<Text fontSize='lg' fontWeight='bold'>
|
||||
{selectedFile.name}
|
||||
<br />
|
||||
<Text as='small' fontWeight='normal'>
|
||||
{helpres.transformBytes(selectedFile.size)}
|
||||
</Text>
|
||||
</Text>
|
||||
) : (
|
||||
<Text fontWeight='normal' color='gray'>
|
||||
Choose a file...
|
||||
</Text>
|
||||
)}
|
||||
{supportedFormats && (
|
||||
<Box mt='1'>
|
||||
<Text as='small' fontSize='xs' color='gray.500'>
|
||||
Supported formats: {supportedFormats.join(', ')}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box p='0 5px'>
|
||||
<Button onClick={onSelectFileHandler} rightIcon={<MdAttachFile />}>
|
||||
Select file
|
||||
</Button>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileSelect;
|
||||
12
src/components/LoadingList/LoadingList.tsx
Normal file
12
src/components/LoadingList/LoadingList.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from '@chakra-ui/layout';
|
||||
|
||||
const LoadingList: React.FC = () => {
|
||||
return (
|
||||
<Box textAlign='center' margin='10px 0' userSelect='none'>
|
||||
<Text color='gray.500'>Wait a second...</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadingList;
|
||||
3
src/components/LoadingList/index.ts
Normal file
3
src/components/LoadingList/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import LoadingList from './LoadingList';
|
||||
|
||||
export default LoadingList;
|
||||
@ -29,6 +29,8 @@ const Select: React.FC<ISelectPropsType> = props => {
|
||||
<MenuButton
|
||||
as={Button}
|
||||
variant='outline'
|
||||
fontWeight='400'
|
||||
fontSize='md'
|
||||
width='full'
|
||||
textAlign='left'
|
||||
justifyContent='flex-end'
|
||||
|
||||
@ -50,3 +50,22 @@ export function transformToFormData(data: object): FormData {
|
||||
|
||||
return formData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform bytes to readable format
|
||||
*
|
||||
* @param bytes Number of bytes
|
||||
* @param decimals
|
||||
* @returns string
|
||||
*/
|
||||
export function transformBytes(bytes: number, decimals = 2): string {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return `${parseFloat((bytes / k ** i).toFixed(dm))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
@ -54,7 +54,7 @@ const AuthForm: React.FC = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<Box w='full' maxW='450px' borderWidth='1px' borderRadius='lg' p='8'>
|
||||
<Box flexBasis='500px' borderWidth='1px' borderRadius='lg' p='8'>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Flex justifyContent='center' alignItems='center'>
|
||||
<Box w='full'>
|
||||
@ -67,27 +67,24 @@ const AuthForm: React.FC = () => {
|
||||
<FormLabel>Login</FormLabel>
|
||||
<InputGroup>
|
||||
<InputLeftElement children={<Icon as={MdAccountCircle} />} />
|
||||
<Input type='text' placeholder='Login' {...register('login')} isInvalid={errors.login ? true : false} />
|
||||
<Input type='text' placeholder='Login' {...register('login')} isInvalid={!!errors.login} />
|
||||
</InputGroup>
|
||||
<Text as='small' color='red.500'>
|
||||
{errors.login?.message}
|
||||
</Text>
|
||||
</FormControl>
|
||||
|
||||
<FormControl mt={5}>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<InputGroup>
|
||||
<InputLeftElement children={<Icon as={MdVpnKey} />} />
|
||||
<Input
|
||||
type='password'
|
||||
placeholder='******'
|
||||
{...register('password')}
|
||||
isInvalid={errors.password ? true : false}
|
||||
/>
|
||||
<Input type='password' placeholder='******' {...register('password')} isInvalid={!!errors.password} />
|
||||
</InputGroup>
|
||||
<Text as='small' color='red.500'>
|
||||
{errors.password?.message}
|
||||
</Text>
|
||||
</FormControl>
|
||||
|
||||
<Button
|
||||
loadingText='Logging in...'
|
||||
isLoading={loading}
|
||||
|
||||
@ -2,7 +2,7 @@ import yup from '@educt/schema';
|
||||
|
||||
const LoginSchema = yup.object().shape({
|
||||
login: yup.string().required('Login is required'),
|
||||
password: yup.string().min(6, 'Password must be at least 6 characters').required('Password is required'),
|
||||
password: yup.string().required('Password is required'),
|
||||
});
|
||||
|
||||
export default LoginSchema;
|
||||
|
||||
@ -7,13 +7,13 @@ import { Box, Grid, Text } from '@chakra-ui/react';
|
||||
*/
|
||||
import { ICourse } from '@educt/interfaces';
|
||||
import { CourseStatusEnum } from '@educt/enums';
|
||||
import { CourseItemPropsType } from './CourseItem';
|
||||
|
||||
/**
|
||||
* Components
|
||||
*/
|
||||
import CourseItem, { CourseItemPropsType } from './CourseItem';
|
||||
import CourseListLoading from './CourseListLoading';
|
||||
import DeleteCourseDialog from '../DeleteCourseDialog';
|
||||
import LoadingList from '@educt/components/LoadingList';
|
||||
|
||||
/**
|
||||
* Hooks
|
||||
@ -84,7 +84,7 @@ const CourseList: React.FC<CourseListPropsType> = ({ render: Item }) => {
|
||||
/**
|
||||
* Loading
|
||||
*/
|
||||
if (courses === null || isLoading) return <CourseListLoading />;
|
||||
if (courses === null || isLoading) return <LoadingList />;
|
||||
|
||||
/**
|
||||
* No courses
|
||||
|
||||
@ -1,12 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from '@chakra-ui/react';
|
||||
|
||||
const CourseListLoading: React.FC = () => {
|
||||
return (
|
||||
<Box textAlign='center' mt='10' userSelect='none'>
|
||||
<Text color='gray.500'>Wait a second...</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CourseListLoading;
|
||||
@ -13,9 +13,10 @@ import {
|
||||
InputRightElement,
|
||||
Input,
|
||||
} from '@chakra-ui/react';
|
||||
import { MdCloudUpload } from 'react-icons/md';
|
||||
import { MdAttachFile, MdCloudUpload } from 'react-icons/md';
|
||||
import { SubmitHandler, useForm, useWatch, Controller } from 'react-hook-form';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import AsyncSelect from '@educt/components/AsyncSelect';
|
||||
|
||||
/**
|
||||
* Types
|
||||
@ -29,18 +30,18 @@ import { CourseStatusEnum, UserRoleEnum } from '@educt/enums';
|
||||
import { useHistory } from 'react-router';
|
||||
import { useRootStore } from '@educt/hooks/useRootStore';
|
||||
import { useErrorHandler } from 'react-error-boundary';
|
||||
import { useColorModeValue } from '@chakra-ui/color-mode';
|
||||
import useIsMountedRef from '@educt/hooks/useIsMountedRef';
|
||||
|
||||
/**
|
||||
* Schema
|
||||
*/
|
||||
import CreateCourseSchema from './CreateCourseForm.validator';
|
||||
import AsyncSelect from '@educt/components/AsyncSelect';
|
||||
import Select from '@educt/components/Select';
|
||||
import FileSelect from '@educt/components/FileSelect/FileSelect';
|
||||
|
||||
type CreateCourseInputType = {
|
||||
title: string;
|
||||
image: FileList;
|
||||
image: File;
|
||||
category_id: string;
|
||||
description: string;
|
||||
teacher_id: string;
|
||||
@ -126,7 +127,7 @@ const CreateCourseForm: React.FC<CreateCourseFormProps> = () => {
|
||||
description: data.description,
|
||||
teacher_id: data.teacher_id,
|
||||
category_id: data.category_id,
|
||||
image: data.image[0],
|
||||
image: data.image,
|
||||
status: status ?? CourseStatusEnum.DRAFT,
|
||||
});
|
||||
toast({ title: `Course successfully created.`, status: 'success' });
|
||||
@ -173,7 +174,13 @@ const CreateCourseForm: React.FC<CreateCourseFormProps> = () => {
|
||||
|
||||
<FormControl id='image' isInvalid={!!errors.image}>
|
||||
<FormLabel>Image</FormLabel>
|
||||
<Input size='md' variant='unstyled' {...register('image')} type='file' accept='image/*' />
|
||||
<Controller
|
||||
control={control}
|
||||
name='image'
|
||||
render={({ field: { onChange } }) => (
|
||||
<FileSelect onChange={file => onChange(file)} supportedFormats={['JPG', 'JPEG', 'PNG', 'WEBP']} />
|
||||
)}
|
||||
/>
|
||||
<Text as='small' color='red.500'>
|
||||
{errors.image?.message}
|
||||
</Text>
|
||||
|
||||
@ -1,20 +1,20 @@
|
||||
import yup from '@educt/schema';
|
||||
|
||||
const FILE_SIZE = 160 * 1024;
|
||||
const SUPPORTED_FORMATS = ['image/jpg', 'image/jpeg', 'image/gif', 'image/png'];
|
||||
const FILE_SIZE = 1000 * 1024;
|
||||
const SUPPORTED_FORMATS = ['image/jpg', 'image/jpeg', 'image/png', 'image/webp'];
|
||||
|
||||
const CreateCourseSchema = yup.object().shape({
|
||||
title: yup.string().required('Title field name is required').max(90, 'Title must be at most 90 characters'),
|
||||
description: yup.string().required('Description field name is required'),
|
||||
image: yup
|
||||
.mixed()
|
||||
.test('fileSize', 'File size is too large', (value: FileList) => {
|
||||
if (!value.length) return true;
|
||||
return value[0] && value[0].size <= FILE_SIZE;
|
||||
.test('fileSize', 'File size is too large', (value: File) => {
|
||||
if (!value) return true;
|
||||
return value && value.size <= FILE_SIZE;
|
||||
})
|
||||
.test('fileFormat', 'This file format is not supported', (value: FileList) => {
|
||||
if (!value.length) return true;
|
||||
return value[0] && SUPPORTED_FORMATS.includes(value[0].type);
|
||||
.test('fileFormat', 'This file format is not supported', (value: File) => {
|
||||
if (!value) return true;
|
||||
return value && SUPPORTED_FORMATS.includes(value.type);
|
||||
})
|
||||
.optional(),
|
||||
category_id: yup.string().required('Category field is required'),
|
||||
|
||||
@ -52,17 +52,19 @@ const UserItem: React.FC<UserItemPropsType> = ({ user, onEdit, onDelete }) => {
|
||||
|
||||
return (
|
||||
<Box borderBottomWidth='1px' borderRadius='lg' w='full' p='3' bg={useColorModeValue('gray.50', 'gray.700')}>
|
||||
<Flex alignItems='center' justifyContent='space-between' sx={{ gap: '20px' }}>
|
||||
<Flex alignItems='center' flexBasis='230px'>
|
||||
<Avatar name={user.fullname} size='sm' mr='3' />
|
||||
<Box>
|
||||
<Flex flexDir={{ base: 'column', sm: 'row' }} alignItems='center' sx={{ gap: '20px' }}>
|
||||
<Flex alignItems='center' flexDir={{ base: 'column', sm: 'row' }} flex='1 1 0'>
|
||||
<Avatar name={user.fullname} size='sm' mr={{ base: '0', sm: '3' }} mb={{ base: '1', sm: '0' }} />
|
||||
<Box textAlign={{ base: 'center', sm: 'left' }}>
|
||||
<Link as={ReactRouterLink} to={`/user/${user.id}`} fontSize='md' fontWeight='medium'>
|
||||
{user.fullname}
|
||||
</Link>
|
||||
<Text fontSize='sm'>{user.email}</Text>
|
||||
<Text fontSize='sm' overflowWrap='anywhere'>
|
||||
{user.email}
|
||||
</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
<Flex sx={{ gap: '5px' }}>
|
||||
<Flex sx={{ gap: '5px' }} justifyContent='center' flex='1 1 0'>
|
||||
<UserBadge roles={user.roles} />
|
||||
</Flex>
|
||||
<Box>
|
||||
|
||||
@ -30,6 +30,7 @@ import { useLocation } from 'react-router';
|
||||
import { useErrorHandler } from 'react-error-boundary';
|
||||
import { useRootStore } from '@educt/hooks/useRootStore';
|
||||
import { useDisclosure } from '@chakra-ui/hooks';
|
||||
import LoadingList from '@educt/components/LoadingList';
|
||||
|
||||
type UserListPropsType = { render: React.FC<UserItemPropsType> };
|
||||
|
||||
@ -47,7 +48,7 @@ const UserList: React.FC<UserListPropsType> = ({ render: Item }) => {
|
||||
userStore.loadUsersData().catch(error => handleError(error));
|
||||
}, [handleError, userStore, location.search]);
|
||||
|
||||
if (pagination === undefined || users === null) return <div>Loading users...</div>;
|
||||
if (pagination === undefined || users === null) return <LoadingList />;
|
||||
|
||||
const pagesCount = Math.ceil(pagination.total / pagination.per_page);
|
||||
|
||||
@ -123,23 +124,21 @@ const UserList: React.FC<UserListPropsType> = ({ render: Item }) => {
|
||||
<Text>Total: ({pagination.total})</Text>
|
||||
<Text>Actions</Text>
|
||||
</Flex>
|
||||
{loading ? (
|
||||
<Box textAlign='center' mt='10' userSelect='none'>
|
||||
<Text color='gray.500'>Wait a second...</Text>
|
||||
</Box>
|
||||
) : (
|
||||
{!loading ? (
|
||||
<Stack mt='4' spacing='2'>
|
||||
{users.map(user => (
|
||||
<Item key={user.id} user={user} onEdit={onEditUser} onDelete={onDeleteUser} />
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<LoadingList />
|
||||
)}
|
||||
</Box>
|
||||
<Flex margin='2rem 0' flexDirection={{ base: 'column', sm: 'column', md: 'row' }}>
|
||||
<Box textAlign={{ base: 'center', sm: 'center', md: 'left' }} mb={{ base: '2', md: '0' }}>
|
||||
Page <b>{pagination.current_page}</b> of {pagesCount}
|
||||
</Box>
|
||||
<Flex ml='auto' mr='auto' alignItems='center' sx={{ gap: '30px' }}>
|
||||
<Flex ml='auto' mr='auto' justifyContent='center' flexWrap='wrap' alignItems='center' sx={{ gap: '30px' }}>
|
||||
<Button
|
||||
onClick={prevPage}
|
||||
disabled={pagination.current_page <= 1}
|
||||
|
||||
@ -43,10 +43,8 @@ const UsersPage: React.FC<IPageProps> = () => {
|
||||
<Flex mt='5' flexDir='column' h='full'>
|
||||
<Flex justifyContent='space-between' sx={{ gap: '10px' }} flexWrap='wrap'>
|
||||
<UserSearch />
|
||||
<Flex sx={{ gap: '10px' }}>
|
||||
<CreateUserForm me={me} />
|
||||
</Flex>
|
||||
</Flex>
|
||||
<UserList render={UserItem} />
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user