mirror of
https://github.com/sergeyyarkov/educt-client.git
synced 2026-10-11 10:19:22 +03:00
Merge branch 'release/1.0.0'
This commit is contained in:
commit
96c2fdfa10
20
.eslintrc.json
Normal file
20
.eslintrc.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2021": true
|
||||
},
|
||||
"extends": ["eslint:recommended", "plugin:react/recommended", "plugin:@typescript-eslint/recommended"],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaFeatures": {
|
||||
"jsx": true
|
||||
},
|
||||
"ecmaVersion": "latest",
|
||||
"sourceType": "module"
|
||||
},
|
||||
"plugins": ["react", "@typescript-eslint"],
|
||||
"rules": {
|
||||
"react/display-name": "off",
|
||||
"react/prop-types": "off"
|
||||
}
|
||||
}
|
||||
@ -1 +1,2 @@
|
||||
export const BACKEND_URL: string = 'http://localhost:3333';
|
||||
export const BACKEND_URL = 'http://localhost:3333';
|
||||
export const WS_URL = 'ws://localhost:3333';
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en" translate="no">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
|
||||
1538
package-lock.json
generated
1538
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
17
package.json
17
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "educt-client",
|
||||
"version": "0.1.0",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
@ -13,9 +13,10 @@
|
||||
"@chakra-ui/react": "^1.6.10",
|
||||
"@emotion/react": "^11.5.0",
|
||||
"@emotion/styled": "^11.3.0",
|
||||
"@hookform/resolvers": "^2.8.0",
|
||||
"@hookform/resolvers": "^2.8.5",
|
||||
"axios": "^0.21.1",
|
||||
"framer-motion": "^4.0.0",
|
||||
"immutability-helper": "^3.1.1",
|
||||
"js-cookie": "^3.0.0",
|
||||
"mobx": "^6.3.2",
|
||||
"mobx-react": "^7.2.0",
|
||||
@ -26,11 +27,13 @@
|
||||
"react-dom": "^17.0.0",
|
||||
"react-error-boundary": "^3.1.3",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-hook-form": "^7.12.2",
|
||||
"react-icons": "^3.11.0",
|
||||
"react-hook-form": "^7.22.5",
|
||||
"react-icons": "^4.3.1",
|
||||
"react-player": "^2.9.0",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-spinners": "^0.11.0",
|
||||
"react-virtuoso": "^2.2.8",
|
||||
"socket.io-client": "^4.4.1",
|
||||
"validator": "^13.6.0",
|
||||
"yup": "^0.32.9"
|
||||
},
|
||||
@ -44,9 +47,13 @@
|
||||
"@types/react-helmet": "^6.1.2",
|
||||
"@types/react-router-dom": "^5.1.8",
|
||||
"@types/validator": "^13.6.3",
|
||||
"@typescript-eslint/eslint-plugin": "^5.6.0",
|
||||
"@typescript-eslint/parser": "^5.6.0",
|
||||
"@vitejs/plugin-react": "^1.0.0",
|
||||
"eslint": "^8.4.1",
|
||||
"eslint-plugin-react": "^7.27.1",
|
||||
"rollup-plugin-visualizer": "^5.5.2",
|
||||
"typescript": "^4.3.2",
|
||||
"typescript": "^4.5.2",
|
||||
"vite": "^2.6.4"
|
||||
}
|
||||
}
|
||||
|
||||
57
src/App.tsx
57
src/App.tsx
@ -5,6 +5,7 @@ import { routes } from './routes.config';
|
||||
/**
|
||||
* Hooks
|
||||
*/
|
||||
import { useContext } from 'react';
|
||||
import { useRootStore } from '@educt/hooks/useRootStore';
|
||||
|
||||
/**
|
||||
@ -12,11 +13,67 @@ import { useRootStore } from '@educt/hooks/useRootStore';
|
||||
*/
|
||||
import PrivateRoute from '@educt/components/PrivateRoute';
|
||||
import PublicRoute from '@educt/components/PublicRoute';
|
||||
import { useSocketEvent } from './hooks/useSocketEvent';
|
||||
import { SocketContext } from './contexts';
|
||||
import { HistoryMessageType, UserOnlineListType, UserSeesionType } from './types';
|
||||
import { useToast } from '@chakra-ui/react';
|
||||
|
||||
const App = () => {
|
||||
const {
|
||||
userStore,
|
||||
uiStore: { history },
|
||||
onlineStore,
|
||||
} = useRootStore();
|
||||
const { socket } = useContext(SocketContext);
|
||||
const toast = useToast();
|
||||
|
||||
useSocketEvent('connect', () => console.info('[WebSocket]: Connected.'));
|
||||
useSocketEvent('disconnect', () => console.info('[WebSocket]: Disconnected.'));
|
||||
useSocketEvent('connect_error', err => console.error(err));
|
||||
useSocketEvent<UserSeesionType>('user:session', data => {
|
||||
if (socket) {
|
||||
const { sessionId } = data;
|
||||
if (sessionId) {
|
||||
socket.auth = { sessionId };
|
||||
window.localStorage.setItem('sessionId', sessionId);
|
||||
}
|
||||
}
|
||||
});
|
||||
useSocketEvent<HistoryMessageType>('chat:message', data => {
|
||||
const chatId = new URLSearchParams(location.search).get('chat_id');
|
||||
const user = onlineStore.getUser(data.from);
|
||||
|
||||
if (user && data.from !== chatId) {
|
||||
if (data.to === data.from) return;
|
||||
|
||||
/**
|
||||
* Notify user about new message
|
||||
*/
|
||||
toast({
|
||||
title: `${user.userName}`,
|
||||
description: data.content,
|
||||
variant: 'left-accent',
|
||||
position: 'bottom-right',
|
||||
status: 'info',
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
if (!window.location.pathname.includes('messages')) {
|
||||
/**
|
||||
* Update user store
|
||||
*/
|
||||
if (data.notificationId) {
|
||||
userStore.addNotification({
|
||||
id: data.notificationId,
|
||||
content: 'You received a new private message',
|
||||
time: new Date().toISOString(),
|
||||
type: 'MESSAGE',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
useSocketEvent<UserOnlineListType>('user:online', online => onlineStore.loadOnline(online));
|
||||
|
||||
return (
|
||||
<Router history={history}>
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { AxiosRequestConfig } from 'axios';
|
||||
import * as constants from '../constants';
|
||||
|
||||
const apiConfig: AxiosRequestConfig = {
|
||||
baseURL: '/api/',
|
||||
baseURL: `${constants.BACKEND_URL}/api/`,
|
||||
responseType: 'json',
|
||||
withCredentials: true,
|
||||
};
|
||||
|
||||
34
src/components/Buttons/index.tsx
Normal file
34
src/components/Buttons/index.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import { ButtonProps, Button } from '@chakra-ui/button';
|
||||
import { AddIcon, DeleteIcon, EditIcon } from '@chakra-ui/icons';
|
||||
import { MdSave } from 'react-icons/md';
|
||||
|
||||
export const CreateButton: React.FC<ButtonProps> = props => (
|
||||
<Button variant='outline' size='sm' loadingText='Creating...' leftIcon={<AddIcon />} {...props}>
|
||||
Create new
|
||||
</Button>
|
||||
);
|
||||
|
||||
export const AddButton: React.FC<ButtonProps> = props => (
|
||||
<Button variant='outline' size='sm' loadingText='Adding...' leftIcon={<AddIcon />} {...props}>
|
||||
Add new
|
||||
</Button>
|
||||
);
|
||||
|
||||
export const DeleteButton: React.FC<ButtonProps> = props => (
|
||||
<Button variant='outline' leftIcon={<DeleteIcon />} loadingText='Deleting...' colorScheme='red' size='sm' {...props}>
|
||||
Delete
|
||||
</Button>
|
||||
);
|
||||
|
||||
export const EditButton: React.FC<ButtonProps> = props => (
|
||||
<Button leftIcon={<EditIcon />} variant='outline' loadingText='Saving...' size='sm' {...props}>
|
||||
Edit
|
||||
</Button>
|
||||
);
|
||||
|
||||
export const SaveButton: React.FC<ButtonProps> = props => (
|
||||
<Button size='sm' variant='outline' loadingText='Saving...' rightIcon={<MdSave />} {...props}>
|
||||
Save
|
||||
</Button>
|
||||
);
|
||||
15
src/components/Card/Card.tsx
Normal file
15
src/components/Card/Card.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import { Box, BoxProps, useColorModeValue } from '@chakra-ui/react';
|
||||
|
||||
const Card: React.FC<BoxProps> = (props: BoxProps) => (
|
||||
<Box
|
||||
bg={useColorModeValue('white', 'gray.700')}
|
||||
py='8'
|
||||
px={{ base: '4', md: '10' }}
|
||||
shadow='base'
|
||||
rounded={{ sm: 'lg' }}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export default Card;
|
||||
3
src/components/Card/index.ts
Normal file
3
src/components/Card/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import Card from './Card';
|
||||
|
||||
export { Card };
|
||||
10
src/components/Chat/ChatContextProvider.tsx
Normal file
10
src/components/Chat/ChatContextProvider.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ChatContext } from './context';
|
||||
|
||||
const ChatContextProvider: React.FC = ({ children }) => {
|
||||
const [search, setSearch] = useState<string>('');
|
||||
|
||||
return <ChatContext.Provider value={{ search, setSearch }}>{children}</ChatContext.Provider>;
|
||||
};
|
||||
|
||||
export { ChatContextProvider };
|
||||
50
src/components/Chat/Sidebar/Heading.tsx
Normal file
50
src/components/Chat/Sidebar/Heading.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Flex,
|
||||
Heading as ChakraHeading,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputLeftElement,
|
||||
HStack,
|
||||
Button,
|
||||
Box,
|
||||
BoxProps,
|
||||
} from '@chakra-ui/react';
|
||||
import { SearchIcon } from '@chakra-ui/icons';
|
||||
|
||||
/**
|
||||
* Hooks
|
||||
*/
|
||||
import { useChatContext } from '../context';
|
||||
|
||||
const Heading: React.FC<BoxProps> = props => {
|
||||
const { search, setSearch } = useChatContext();
|
||||
|
||||
const onSearch = (e: React.ChangeEvent<HTMLInputElement>) => setSearch(e.target.value);
|
||||
|
||||
return (
|
||||
<Box {...props}>
|
||||
<Flex my='4' pr={'3'} justifyContent={'space-between'} alignItems={'center'}>
|
||||
<ChakraHeading as='h2' fontSize={'md'} fontWeight='medium' mr='9'>
|
||||
Chats
|
||||
</ChakraHeading>
|
||||
<InputGroup maxW='230px' size='sm'>
|
||||
<InputLeftElement pointerEvents='none'>
|
||||
<SearchIcon color='gray.300' />
|
||||
</InputLeftElement>
|
||||
<Input type='text' onChange={onSearch} value={search} placeholder='Search by name...' borderRadius={'md'} />
|
||||
</InputGroup>
|
||||
</Flex>
|
||||
<HStack>
|
||||
<Button isActive variant={'ghost'} size='sm'>
|
||||
All
|
||||
</Button>
|
||||
{/* <Button colorScheme={'blue'} variant='ghost' size='sm'>
|
||||
Unread
|
||||
</Button> */}
|
||||
</HStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export { Heading };
|
||||
22
src/components/Chat/Sidebar/Sidebar.tsx
Normal file
22
src/components/Chat/Sidebar/Sidebar.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import { BoxProps, GridItem, GridItemProps } from '@chakra-ui/react';
|
||||
import { Heading } from './Heading';
|
||||
import { Users } from './Users/Users';
|
||||
|
||||
export interface ISidebarComposition {
|
||||
Heading: React.FC<BoxProps>;
|
||||
Users: React.FC;
|
||||
}
|
||||
|
||||
const Sidebar: React.FC<GridItemProps> & ISidebarComposition = props => {
|
||||
return (
|
||||
<GridItem pt='7rem' borderRightWidth={{ base: '0', lg: '1px' }} px={{ base: '0', lg: '3' }} {...props}>
|
||||
{props.children}
|
||||
</GridItem>
|
||||
);
|
||||
};
|
||||
|
||||
Sidebar.Heading = Heading;
|
||||
Sidebar.Users = Users;
|
||||
|
||||
export { Sidebar };
|
||||
32
src/components/Chat/Sidebar/Users/User/Avatar.tsx
Normal file
32
src/components/Chat/Sidebar/Users/User/Avatar.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import { Avatar as ChakraAvatar, AvatarBadge, WrapItem } from '@chakra-ui/react';
|
||||
import { useRootStore } from '@educt/hooks/useRootStore';
|
||||
import { MdOutlineBookmark } from 'react-icons/md';
|
||||
|
||||
export interface IAvatarProps {
|
||||
fullname: string;
|
||||
isOnline: boolean;
|
||||
}
|
||||
|
||||
const Avatar: React.FC<IAvatarProps> = ({ fullname: name, isOnline }) => {
|
||||
const {
|
||||
userStore: { me },
|
||||
} = useRootStore();
|
||||
const isMe = me?.fullname === name;
|
||||
const onlineBg = isOnline ? 'green.500' : 'red.500';
|
||||
|
||||
return (
|
||||
<WrapItem>
|
||||
<ChakraAvatar
|
||||
name={isMe ? undefined : name}
|
||||
bg={isMe ? 'blue.400' : undefined}
|
||||
icon={<MdOutlineBookmark color='white' />}
|
||||
size='md'
|
||||
>
|
||||
{!isMe && <AvatarBadge boxSize='0.90em' bg={onlineBg} />}
|
||||
</ChakraAvatar>
|
||||
</WrapItem>
|
||||
);
|
||||
};
|
||||
|
||||
export { Avatar };
|
||||
32
src/components/Chat/Sidebar/Users/User/Info.tsx
Normal file
32
src/components/Chat/Sidebar/Users/User/Info.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from '@chakra-ui/react';
|
||||
import { useRootStore } from '@educt/hooks/useRootStore';
|
||||
import { HistoryMessageType } from '@educt/types';
|
||||
|
||||
export interface IInfoProps {
|
||||
fullname: string;
|
||||
lastMessage?: HistoryMessageType | undefined;
|
||||
}
|
||||
|
||||
const Info: React.FC<IInfoProps> = ({ fullname, lastMessage }) => {
|
||||
const {
|
||||
userStore: { me },
|
||||
} = useRootStore();
|
||||
const isMe = me?.fullname === fullname;
|
||||
|
||||
return (
|
||||
<Box ml='4' maxW='200px'>
|
||||
<Text fontWeight={'semibold'}>
|
||||
{isMe ? 'Favorites' : fullname} <br />
|
||||
{lastMessage && (
|
||||
<Text as={'span'} fontWeight='normal' fontSize={'sm'} color='gray.500' wordBreak={'break-all'}>
|
||||
{lastMessage.from === me?.id && `You: `}
|
||||
{lastMessage.content.length >= 20 ? lastMessage.content.slice(0, 20 - 1) + '...' : lastMessage.content}
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export { Info };
|
||||
18
src/components/Chat/Sidebar/Users/User/Time.tsx
Normal file
18
src/components/Chat/Sidebar/Users/User/Time.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from '@chakra-ui/react';
|
||||
|
||||
export interface ITimeProps {
|
||||
time: string;
|
||||
}
|
||||
|
||||
const Time: React.FC<ITimeProps> = ({ time }) => {
|
||||
return (
|
||||
<Box alignSelf={'flex-end'} ml='auto'>
|
||||
<Text as='span' color='gray.500'>
|
||||
{time}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export { Time };
|
||||
55
src/components/Chat/Sidebar/Users/User/User.tsx
Normal file
55
src/components/Chat/Sidebar/Users/User/User.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
import { Box, Flex } from '@chakra-ui/react';
|
||||
import { IInfoProps, Info } from './Info';
|
||||
import { ITimeProps, Time } from './Time';
|
||||
import { IAvatarProps, Avatar } from './Avatar';
|
||||
|
||||
/**
|
||||
* Hooks
|
||||
*/
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { useColorModeValue } from '@chakra-ui/react';
|
||||
|
||||
interface IUserComposition {
|
||||
Avatar: React.FC<IAvatarProps>;
|
||||
Info: React.FC<IInfoProps>;
|
||||
Time: React.FC<ITimeProps>;
|
||||
}
|
||||
|
||||
interface IUserProps {
|
||||
id: string;
|
||||
}
|
||||
|
||||
const User: React.FC<IUserProps> & IUserComposition = props => {
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const chatId = new URLSearchParams(location.search).get('chat_id');
|
||||
const bg = useColorModeValue('gray.50', 'gray.700');
|
||||
|
||||
const handleChat = () => {
|
||||
history.replace({ pathname: location.pathname, search: new URLSearchParams({ ['chat_id']: props.id }).toString() });
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
onClick={handleChat}
|
||||
py='5'
|
||||
px='4'
|
||||
borderRadius={'lg'}
|
||||
bg={chatId === props.id ? bg : ''}
|
||||
_hover={{ bg }}
|
||||
transition='all .1s'
|
||||
cursor={'pointer'}
|
||||
>
|
||||
<Flex flexDir={'row'} alignItems={'center'}>
|
||||
{props.children}
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
User.Avatar = Avatar;
|
||||
User.Info = Info;
|
||||
User.Time = Time;
|
||||
|
||||
export { User };
|
||||
1
src/components/Chat/Sidebar/Users/User/index.ts
Normal file
1
src/components/Chat/Sidebar/Users/User/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { User } from './User';
|
||||
67
src/components/Chat/Sidebar/Users/Users.tsx
Normal file
67
src/components/Chat/Sidebar/Users/Users.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
import { observer } from 'mobx-react';
|
||||
import { Stack, StackProps } from '@chakra-ui/react';
|
||||
import { User } from './User';
|
||||
import { useChatContext } from '../../context';
|
||||
import { useRootStore } from '@educt/hooks/useRootStore';
|
||||
import { Skeleton } from '@chakra-ui/react';
|
||||
import { useFetchConversations } from '@educt/hooks/queries';
|
||||
|
||||
const Wrapper: React.FC<StackProps> = ({ children }) => {
|
||||
return (
|
||||
<Stack mt='12' maxH='690px' overflowY={'scroll'} pr={{ base: '0', lg: '3' }}>
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const Users: React.FC = observer(() => {
|
||||
const {
|
||||
userStore: { me },
|
||||
onlineStore,
|
||||
} = useRootStore();
|
||||
const { data: conversations } = useFetchConversations();
|
||||
const { search } = useChatContext();
|
||||
|
||||
if (conversations === null || me === null)
|
||||
return (
|
||||
<Wrapper>
|
||||
<Skeleton h='90px' borderRadius={'lg'} />
|
||||
<Skeleton h='90px' borderRadius={'lg'} />
|
||||
</Wrapper>
|
||||
);
|
||||
|
||||
const filtered = (
|
||||
!search ? conversations : conversations.filter(u => u.fullname.toLocaleLowerCase().includes(search))
|
||||
)
|
||||
.concat()
|
||||
.sort((c1, c2) => new Date(c2.lastMessage.time).valueOf() - new Date(c1.lastMessage.time).valueOf());
|
||||
|
||||
const isEmpty = filtered.length === 0;
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
{/* Favorites */}
|
||||
<User id={me.id}>
|
||||
<User.Avatar isOnline fullname={me.fullname} />
|
||||
<User.Info fullname={me.fullname} />
|
||||
</User>
|
||||
|
||||
{/* User list */}
|
||||
{!isEmpty &&
|
||||
filtered.map(
|
||||
c =>
|
||||
c.userId !== me.id && (
|
||||
<User key={c.userId} id={c.userId}>
|
||||
<User.Avatar isOnline={onlineStore.isOnline(c.userId) || false} fullname={c.fullname} />
|
||||
<User.Info fullname={c.fullname} lastMessage={c.lastMessage} />
|
||||
<User.Time
|
||||
time={new Date(c.lastMessage.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
/>
|
||||
</User>
|
||||
)
|
||||
)}
|
||||
</Wrapper>
|
||||
);
|
||||
});
|
||||
export { Users };
|
||||
1
src/components/Chat/Sidebar/Users/index.ts
Normal file
1
src/components/Chat/Sidebar/Users/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { Users } from './Users';
|
||||
1
src/components/Chat/Sidebar/index.ts
Normal file
1
src/components/Chat/Sidebar/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { Sidebar } from './Sidebar';
|
||||
16
src/components/Chat/Window/Message/Avatar.tsx
Normal file
16
src/components/Chat/Window/Message/Avatar.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import React, { memo } from 'react';
|
||||
import { WrapItem, Avatar as ChakraAvatar } from '@chakra-ui/react';
|
||||
|
||||
export interface IAvatarProps {
|
||||
fullname: string;
|
||||
}
|
||||
|
||||
const Avatar: React.FC<IAvatarProps> = memo(({ fullname: name }) => {
|
||||
return (
|
||||
<WrapItem alignSelf={'flex-start'}>
|
||||
<ChakraAvatar name={name} size={'sm'} />
|
||||
</WrapItem>
|
||||
);
|
||||
});
|
||||
|
||||
export { Avatar };
|
||||
59
src/components/Chat/Window/Message/Message.tsx
Normal file
59
src/components/Chat/Window/Message/Message.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import React, { memo, useEffect, useRef } from 'react';
|
||||
import { Flex, Box, Stack, Text as ChakraText } from '@chakra-ui/react';
|
||||
import { Avatar, IAvatarProps } from './Avatar';
|
||||
import { Text, ITextProps } from './Text';
|
||||
import { MessageContext } from './context';
|
||||
import { MessageType } from '@educt/types';
|
||||
|
||||
interface IMessageComposition {
|
||||
Avatar: React.FC<IAvatarProps>;
|
||||
Text: React.FC<ITextProps>;
|
||||
}
|
||||
|
||||
interface IMyMessageProps {
|
||||
isMyMessage: boolean;
|
||||
}
|
||||
|
||||
const MessageList: React.FC<{ messages: MessageType[] }> = memo(({ messages }) => {
|
||||
const listRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (listRef.current) {
|
||||
listRef.current.scrollTo(0, listRef.current.scrollHeight);
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
return (
|
||||
<Stack ref={listRef} h='65vh' overflowY={'scroll'} mb='12' mt='6' spacing={'3'} pr='8'>
|
||||
{messages.length !== 0 ? (
|
||||
messages.map((message, i) => (
|
||||
<Message key={i} isMyMessage={message.isMyMessage}>
|
||||
<Message.Avatar fullname={message.userName} />
|
||||
<Message.Text content={message.content} time={message.time} />
|
||||
</Message>
|
||||
))
|
||||
) : (
|
||||
<Box>
|
||||
<ChakraText mt='5' textAlign={'center'} color='gray.500' userSelect={'none'}>
|
||||
Your message history will be displayed here.
|
||||
</ChakraText>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
});
|
||||
|
||||
const Message: React.FC<IMyMessageProps> & IMessageComposition = ({ children, isMyMessage }) => {
|
||||
return (
|
||||
<MessageContext.Provider value={{ isMyMessage }}>
|
||||
<Flex flexDir={!isMyMessage ? 'row' : 'row-reverse'} alignItems={'center'}>
|
||||
{children}
|
||||
</Flex>
|
||||
</MessageContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
Message.Avatar = Avatar;
|
||||
Message.Text = Text;
|
||||
|
||||
export { MessageList, Message };
|
||||
38
src/components/Chat/Window/Message/Text.tsx
Normal file
38
src/components/Chat/Window/Message/Text.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import React from 'react';
|
||||
import { Box, Text as ChakraText, useColorModeValue } from '@chakra-ui/react';
|
||||
import { useMessageContext } from './context';
|
||||
|
||||
export interface ITextProps {
|
||||
content: string;
|
||||
time: string;
|
||||
}
|
||||
|
||||
const Text: React.FC<ITextProps> = ({ content, time }) => {
|
||||
const { isMyMessage } = useMessageContext();
|
||||
|
||||
const bgColor = !isMyMessage ? useColorModeValue('gray.50', 'gray.700') : useColorModeValue('gray.100', 'gray.600');
|
||||
|
||||
return (
|
||||
<Box mx={'4'} maxW={'450px'} bg={bgColor} borderRadius='3xl' px='6' py='3'>
|
||||
<ChakraText fontSize={'15px'} wordBreak='break-all'>
|
||||
{content}
|
||||
<Box
|
||||
as='time'
|
||||
userSelect={'none'}
|
||||
position={'relative'}
|
||||
left='10px'
|
||||
float='right'
|
||||
top='8px'
|
||||
alignSelf={'flex-end'}
|
||||
color='gray.500'
|
||||
fontSize={'xs'}
|
||||
dateTime={time}
|
||||
>
|
||||
{time}
|
||||
</Box>
|
||||
</ChakraText>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export { Text };
|
||||
17
src/components/Chat/Window/Message/context.ts
Normal file
17
src/components/Chat/Window/Message/context.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
export type MessageContextType = {
|
||||
isMyMessage: boolean;
|
||||
};
|
||||
|
||||
export const MessageContext = createContext<MessageContextType | undefined>(undefined);
|
||||
|
||||
export const useMessageContext = () => {
|
||||
const context = useContext(MessageContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('MessageContext was outside of its provider');
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
1
src/components/Chat/Window/Message/index.ts
Normal file
1
src/components/Chat/Window/Message/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './Message';
|
||||
215
src/components/Chat/Window/Window.tsx
Normal file
215
src/components/Chat/Window/Window.tsx
Normal file
@ -0,0 +1,215 @@
|
||||
import React, { ChangeEvent } from 'react';
|
||||
import axios from 'axios';
|
||||
import { Link as ReactRouterLink } from 'react-router-dom';
|
||||
import {
|
||||
GridItem,
|
||||
GridItemProps,
|
||||
Flex,
|
||||
Box,
|
||||
Icon,
|
||||
Text,
|
||||
Input,
|
||||
Heading,
|
||||
IconButton,
|
||||
Divider,
|
||||
Link,
|
||||
} from '@chakra-ui/react';
|
||||
import { MdOutlineChat, MdSend } from 'react-icons/md';
|
||||
import { MessageList } from './Message';
|
||||
import { SocketContext } from '@educt/contexts';
|
||||
import { HistoryMessageType, MessageType } from '@educt/types';
|
||||
import { UserServiceInstance } from '@educt/services';
|
||||
import { IUser } from '@educt/interfaces';
|
||||
import LoadingList from '@educt/components/LoadingList';
|
||||
|
||||
/**
|
||||
* Hooks
|
||||
*/
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
import { useToast } from '@chakra-ui/react';
|
||||
import { useSocketEvent } from '@educt/hooks/useSocketEvent';
|
||||
import { useRootStore } from '@educt/hooks/useRootStore';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
const Wrapper: React.FC<GridItemProps> = props => (
|
||||
<GridItem pt={'7rem'} pb='5' px={{ base: '4', lg: '12' }} {...props}>
|
||||
{props.children}
|
||||
</GridItem>
|
||||
);
|
||||
|
||||
const Window: React.FC = props => {
|
||||
const {
|
||||
userStore: { me },
|
||||
onlineStore,
|
||||
} = useRootStore();
|
||||
const chatId = new URLSearchParams(location.search).get('chat_id');
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [selectedUser, setSelectedUser] = useState<IUser | null>(null);
|
||||
const [inputValue, setInputValue] = useState<string>('');
|
||||
const [messages, setMessages] = useState<MessageType[]>([]);
|
||||
const { socket } = useContext(SocketContext);
|
||||
const history = useHistory();
|
||||
const toast = useToast();
|
||||
|
||||
const isMe = me?.id === selectedUser?.id;
|
||||
|
||||
const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (inputValue === '' || me === null) return;
|
||||
|
||||
const message: MessageType = {
|
||||
userName: me.fullname,
|
||||
content: inputValue,
|
||||
time: new Date().toLocaleTimeString(),
|
||||
isMyMessage: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Emit event to server and update messages state
|
||||
*/
|
||||
if (chatId) {
|
||||
socket?.emit('chat:message', {
|
||||
content: message.content,
|
||||
to: chatId,
|
||||
});
|
||||
setMessages(prev => [...prev, message]);
|
||||
setInputValue('');
|
||||
}
|
||||
};
|
||||
|
||||
const onChangeInputValue = (e: ChangeEvent<HTMLInputElement>) => setInputValue(e.target.value);
|
||||
|
||||
/**
|
||||
* Load user data by `chat_id`
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (chatId) {
|
||||
(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const { data: user } = await UserServiceInstance.fetchUserById(chatId);
|
||||
const {
|
||||
data: { history },
|
||||
} = await UserServiceInstance.fetchChatHistory(chatId);
|
||||
|
||||
const messages = history.map(message => {
|
||||
return {
|
||||
isMyMessage: message.from === me?.id,
|
||||
content: message.content,
|
||||
time: new Date(message.time).toLocaleTimeString(),
|
||||
userName: message.from === me?.id ? me?.fullname : user.fullname,
|
||||
};
|
||||
});
|
||||
|
||||
setSelectedUser(user);
|
||||
setMessages(messages);
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
if (error.response?.status === 404) {
|
||||
toast({ title: 'Chat not found.', status: 'warning' });
|
||||
history.replace({ search: '' });
|
||||
}
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
})();
|
||||
}
|
||||
}, [chatId]);
|
||||
|
||||
/**
|
||||
* Event on accept new messages (set new message to state)
|
||||
*/
|
||||
useSocketEvent<HistoryMessageType>('chat:message', data => {
|
||||
const user = onlineStore.getUser(data.from);
|
||||
|
||||
if (user) {
|
||||
if (chatId === data.from) {
|
||||
/**
|
||||
* Do not add duplicate of message
|
||||
*/
|
||||
if (data.to === data.from) return;
|
||||
|
||||
const message: MessageType = {
|
||||
content: data.content,
|
||||
userName: user.userName,
|
||||
isMyMessage: false,
|
||||
time: new Date(data.time).toLocaleTimeString(),
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, message]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Wrapper>
|
||||
<LoadingList />
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unselected user
|
||||
*/
|
||||
if (!chatId || !selectedUser) {
|
||||
return (
|
||||
<Wrapper>
|
||||
<Box textAlign={'center'} mt={{ base: '0', lg: '15rem' }}>
|
||||
<Icon as={MdOutlineChat} color='gray.600' w='16' h='16' />
|
||||
<Text color='gray.500' userSelect={'none'}>
|
||||
Select a chat to start messaging
|
||||
</Text>
|
||||
</Box>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrapper {...props}>
|
||||
<Flex flexDir={'column'}>
|
||||
<Flex my='3' alignItems={'flex-end'} justifyContent={'space-between'}>
|
||||
<Heading as='h2' fontWeight={'semibold'} fontSize='3xl'>
|
||||
{isMe && me ? 'Favorites' : selectedUser.fullname}
|
||||
</Heading>
|
||||
{!isMe && (
|
||||
<Link as={ReactRouterLink} to={`/user/${selectedUser.id}`} textDecor='underline'>
|
||||
See profile
|
||||
</Link>
|
||||
)}
|
||||
</Flex>
|
||||
<Divider my='2' />
|
||||
<MessageList messages={messages} />
|
||||
<form onSubmit={e => onSubmit(e)}>
|
||||
<Flex mt='auto' alignItems={'flex-end'}>
|
||||
<Input
|
||||
onChange={onChangeInputValue}
|
||||
value={inputValue}
|
||||
resize={'block'}
|
||||
type='text'
|
||||
placeholder='Write a message...'
|
||||
variant={'outline'}
|
||||
rounded='xl'
|
||||
mr='2'
|
||||
/>
|
||||
<IconButton
|
||||
type='submit'
|
||||
icon={<MdSend />}
|
||||
aria-label='Send message'
|
||||
variant={'ghost'}
|
||||
_hover={{ bg: 'inherit' }}
|
||||
_active={{ bg: 'inherit' }}
|
||||
_focus={{ outline: 'none' }}
|
||||
/>
|
||||
</Flex>
|
||||
</form>
|
||||
</Flex>
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export { Window };
|
||||
1
src/components/Chat/Window/index.ts
Normal file
1
src/components/Chat/Window/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { Window } from './Window';
|
||||
21
src/components/Chat/context.ts
Normal file
21
src/components/Chat/context.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import React, { useContext, createContext } from 'react';
|
||||
|
||||
export type ChatContextType = {
|
||||
search: string;
|
||||
setSearch: React.Dispatch<React.SetStateAction<string>>;
|
||||
};
|
||||
|
||||
export const ChatContext = createContext<ChatContextType>({
|
||||
search: '',
|
||||
setSearch: () => undefined,
|
||||
});
|
||||
|
||||
export const useChatContext = () => {
|
||||
const context = useContext(ChatContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('ChatContext was outside of its provider');
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
26
src/components/Chat/index.tsx
Normal file
26
src/components/Chat/index.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import { Grid, GridItemProps, GridProps } from '@chakra-ui/react';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { Window } from './Window';
|
||||
import { ISidebarComposition } from './Sidebar/Sidebar';
|
||||
import { ChatContextProvider } from './ChatContextProvider';
|
||||
|
||||
interface IChatComposition {
|
||||
Sidebar: React.FC<GridItemProps> & ISidebarComposition;
|
||||
Window: React.FC<GridItemProps>;
|
||||
}
|
||||
|
||||
const Chat: React.FC<GridProps> & IChatComposition = props => {
|
||||
return (
|
||||
<ChatContextProvider>
|
||||
<Grid gridTemplateColumns={{ base: '1fr', lg: '1fr 3fr' }} h='100vh' {...props}>
|
||||
{props.children}
|
||||
</Grid>
|
||||
</ChatContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
Chat.Sidebar = Sidebar;
|
||||
Chat.Window = Window;
|
||||
|
||||
export { Chat };
|
||||
@ -2,7 +2,7 @@ import * as React from 'react';
|
||||
import { useColorMode, useColorModeValue, IconButton, IconButtonProps } from '@chakra-ui/react';
|
||||
import { FaMoon, FaSun } from 'react-icons/fa';
|
||||
|
||||
type ColorModeSwitcherProps = Omit<IconButtonProps, 'aria-label'>;
|
||||
export type ColorModeSwitcherProps = Omit<IconButtonProps, 'aria-label'>;
|
||||
|
||||
const ColorModeSwitcher: React.FC<ColorModeSwitcherProps> = props => {
|
||||
const { toggleColorMode } = useColorMode();
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React from 'react';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogBody,
|
||||
|
||||
3
src/components/ConfirmDialog/index.ts
Normal file
3
src/components/ConfirmDialog/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import ConfirmDialog from './ConfirmDialog';
|
||||
|
||||
export default ConfirmDialog;
|
||||
@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
import ConfirmDialog from '@educt/components/ConfirmDialog';
|
||||
import { ICategory } from '@educt/interfaces';
|
||||
import useIsMountedRef from '@educt/hooks/useIsMountedRef';
|
||||
import { useDeleteCategory } from '@educt/hooks/queries';
|
||||
|
||||
type DeleteCategoryDialogPropsType = {
|
||||
category: ICategory;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onDeleted?: (category: ICategory) => void | undefined;
|
||||
};
|
||||
|
||||
const DeleteCategoryDialog: React.FC<DeleteCategoryDialogPropsType> = ({ onClose, onDeleted, isOpen, category }) => {
|
||||
const { deleteCategory, isLoading } = useDeleteCategory();
|
||||
const isMountedRef = useIsMountedRef();
|
||||
const handleDelete = async (isConfirmed: boolean, id: string) => {
|
||||
if (isConfirmed) {
|
||||
try {
|
||||
await deleteCategory(id);
|
||||
onDeleted && onDeleted(category);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (isMountedRef.current) {
|
||||
/**
|
||||
* Close dialog
|
||||
*/
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmDialog
|
||||
isOpen={isOpen}
|
||||
isLoading={isLoading}
|
||||
onClose={isConfirmed => handleDelete(isConfirmed, category.id)}
|
||||
title='Confirm deleting'
|
||||
message={`The category with title "${category.title}" will be removed from the system. After deleting a category, the courses in it will also be deleted.`}
|
||||
confirmMessage='Delete'
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteCategoryDialog;
|
||||
3
src/components/Dialogs/DeleteCategoryDialog/index.ts
Normal file
3
src/components/Dialogs/DeleteCategoryDialog/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import DeleteCategoryDialog from './DeleteCategoryDialog';
|
||||
|
||||
export default DeleteCategoryDialog;
|
||||
@ -0,0 +1,64 @@
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* Types
|
||||
*/
|
||||
import { ICourse } from '@educt/interfaces';
|
||||
|
||||
/**
|
||||
* Components
|
||||
*/
|
||||
import ConfirmDialog from '@educt/components/ConfirmDialog/ConfirmDialog';
|
||||
|
||||
/**
|
||||
* Hooks
|
||||
*/
|
||||
import { useDeleteCourse } from '@educt/hooks/queries/course/useDeleteCourse';
|
||||
import { useHistory, useLocation } from 'react-router';
|
||||
import useIsMountedRef from '@educt/hooks/useIsMountedRef';
|
||||
|
||||
type DeleteCourseDialogPropsType = {
|
||||
course: Pick<ICourse, 'id' | 'title'>;
|
||||
onClose: () => void;
|
||||
isOpen: boolean;
|
||||
};
|
||||
|
||||
const DeleteCourseDialog: React.FC<DeleteCourseDialogPropsType> = ({ onClose, isOpen, course }) => {
|
||||
const { deleteCourse, isLoading } = useDeleteCourse();
|
||||
const isMountedRef = useIsMountedRef();
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
|
||||
const handleDelete = async (isConfirmed: boolean, id: string) => {
|
||||
if (isConfirmed) {
|
||||
try {
|
||||
await deleteCourse(id);
|
||||
if (isMountedRef.current && location.pathname !== '/courses') {
|
||||
history.push('/courses');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (isMountedRef.current) {
|
||||
/**
|
||||
* Close dialog
|
||||
*/
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmDialog
|
||||
isOpen={isOpen}
|
||||
isLoading={isLoading}
|
||||
onClose={isConfirmed => handleDelete(isConfirmed, course.id)}
|
||||
title='Confirm deleting'
|
||||
message={`The course with title "${course.title}" will be removed from the system. After deleting a course, the lessons in it will also be deleted.`}
|
||||
confirmMessage='Delete'
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteCourseDialog;
|
||||
@ -0,0 +1,56 @@
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* Types
|
||||
*/
|
||||
import { ILesson } from '@educt/interfaces';
|
||||
|
||||
/**
|
||||
* Components
|
||||
*/
|
||||
import ConfirmDialog from '@educt/components/ConfirmDialog/ConfirmDialog';
|
||||
|
||||
/**
|
||||
* Hooks
|
||||
*/
|
||||
import { useDeleteLesson } from '@educt/hooks/queries';
|
||||
|
||||
type DeleteLessonDialogPropsType = {
|
||||
lesson: Pick<ILesson, 'id' | 'title'>;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirmed?: (id: string) => void | undefined;
|
||||
};
|
||||
|
||||
const DeleteLessonDialog: React.FC<DeleteLessonDialogPropsType> = ({ lesson, isOpen, onConfirmed, onClose }) => {
|
||||
const { deleteLesson, isLoading } = useDeleteLesson();
|
||||
|
||||
const handleDelete = async (isConfirmed: boolean) => {
|
||||
if (isConfirmed) {
|
||||
try {
|
||||
await deleteLesson(lesson.id);
|
||||
onConfirmed && onConfirmed(lesson.id);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close dialog
|
||||
*/
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfirmDialog
|
||||
isOpen={isOpen}
|
||||
isLoading={isLoading}
|
||||
onClose={isConfirmed => handleDelete(isConfirmed)}
|
||||
title='Confirm deleting'
|
||||
message={`Lesson with will be removed from the course. Attached materials will also be removed.`}
|
||||
confirmMessage='Delete'
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeleteLessonDialog;
|
||||
3
src/components/Dialogs/DeleteLessonDialog/index.ts
Normal file
3
src/components/Dialogs/DeleteLessonDialog/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import DeleteLessonDialog from './DeleteLessonDialog';
|
||||
|
||||
export default DeleteLessonDialog;
|
||||
@ -18,23 +18,20 @@ import { UsersPageContext } from '@educt/contexts';
|
||||
/**
|
||||
* Hooks
|
||||
*/
|
||||
import { useContext, useState } from 'react';
|
||||
import { useContext } from 'react';
|
||||
import { useRootStore } from '@educt/hooks/useRootStore';
|
||||
import { useToast } from '@chakra-ui/react';
|
||||
import { useErrorHandler } from 'react-error-boundary';
|
||||
import { useDeleteUser } from '@educt/hooks/queries';
|
||||
|
||||
type DeleteUserDialogPropsType = {
|
||||
user: IUser;
|
||||
user: Pick<IUser, 'id' | 'fullname'>;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const DeleteUserDialog: React.FC<DeleteUserDialogPropsType> = ({ isOpen, onClose, user }) => {
|
||||
const { userStore } = useRootStore();
|
||||
const { setDeletingUser, searchingRole, searchingPage, search } = useContext(UsersPageContext);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const handleError = useErrorHandler();
|
||||
const toast = useToast();
|
||||
const { searchingRole, searchingPage, search } = useContext(UsersPageContext);
|
||||
const { deleteUser, isLoading } = useDeleteUser();
|
||||
|
||||
const { pagination } = userStore;
|
||||
|
||||
@ -47,23 +44,14 @@ const DeleteUserDialog: React.FC<DeleteUserDialogPropsType> = ({ isOpen, onClose
|
||||
const deleteUserHandler = async (isConfirmed: boolean, id: string) => {
|
||||
if (isConfirmed) {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await userStore.deleteUser(id, {
|
||||
await deleteUser(id, {
|
||||
page: searchingPage,
|
||||
limit: pagination?.per_page,
|
||||
role: searchingRole,
|
||||
search,
|
||||
});
|
||||
toast({ title: 'User deleted.', status: 'info' });
|
||||
} catch (error: any) {
|
||||
if (error.response) {
|
||||
toast({ title: error.message, status: 'error' });
|
||||
} else {
|
||||
handleError(error);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setDeletingUser(undefined);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import React from 'react';
|
||||
import Cookies from 'js-cookie';
|
||||
import { Flex, Box, Heading, Text } from '@chakra-ui/react';
|
||||
import axios, { AxiosError } from 'axios';
|
||||
import { Redirect } from 'react-router';
|
||||
import { Icon, Box, Heading, Text } from '@chakra-ui/react';
|
||||
import { MdError } from 'react-icons/md';
|
||||
|
||||
/**
|
||||
@ -9,9 +11,8 @@ import { MdError } from 'react-icons/md';
|
||||
import { useEffect } from 'react';
|
||||
import { useToast } from '@chakra-ui/toast';
|
||||
import { useRootStore } from '@educt/hooks/useRootStore';
|
||||
import { Redirect } from 'react-router';
|
||||
|
||||
const ErrorFallback: React.FC<{ error: any }> = props => {
|
||||
const ErrorFallback: React.FC<{ error: Error | AxiosError }> = props => {
|
||||
const { userStore, authStore } = useRootStore();
|
||||
const { error } = props;
|
||||
const toast = useToast();
|
||||
@ -20,19 +21,19 @@ const ErrorFallback: React.FC<{ error: any }> = props => {
|
||||
toast({ title: `${error.message}`, duration: 10000, isClosable: true, status: 'error' });
|
||||
});
|
||||
|
||||
if (error.response) {
|
||||
if (error.response.status === 401) {
|
||||
userStore.me = null;
|
||||
authStore.setIsLoggedIn(false);
|
||||
Cookies.remove('logged_in');
|
||||
} else if (error.response.status === 404) return <Redirect to='/404' />;
|
||||
if (axios.isAxiosError(error)) {
|
||||
if (error.response) {
|
||||
if (error.response.status === 401) {
|
||||
userStore.me = null;
|
||||
authStore.setIsLoggedIn(false);
|
||||
Cookies.remove('logged_in');
|
||||
} else if (error.response.status === 404) return <Redirect to='/404' />;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box textAlign='center' mt={40}>
|
||||
<Flex justifyContent='center'>
|
||||
<MdError size='64px' />
|
||||
</Flex>
|
||||
<Icon as={MdError} w='16' h='16' />
|
||||
<Heading display='block' as='h1' fontSize='6xl'>
|
||||
Something went wrong
|
||||
</Heading>
|
||||
|
||||
80
src/components/Fields/PasswordField.tsx
Normal file
80
src/components/Fields/PasswordField.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import React, { useRef } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
Text,
|
||||
IconButton,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputProps,
|
||||
InputRightElement,
|
||||
InputLeftElement,
|
||||
Icon,
|
||||
useDisclosure,
|
||||
useMergeRefs,
|
||||
useColorModeValue as mode,
|
||||
} from '@chakra-ui/react';
|
||||
import { HiEye, HiEyeOff } from 'react-icons/hi';
|
||||
import { MdVpnKey } from 'react-icons/md';
|
||||
|
||||
interface PasswordFieldPropsType extends InputProps {
|
||||
errorMessage?: string | undefined;
|
||||
}
|
||||
|
||||
export const PasswordField = React.forwardRef<HTMLInputElement, PasswordFieldPropsType>((props, ref) => {
|
||||
const { errorMessage, ...other } = props;
|
||||
const { isOpen, onToggle } = useDisclosure();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const mergeRef = useMergeRefs(inputRef, ref);
|
||||
|
||||
const onClickReveal = () => {
|
||||
onToggle();
|
||||
const input = inputRef.current;
|
||||
if (input) {
|
||||
input.focus({ preventScroll: true });
|
||||
const length = input.value.length * 2;
|
||||
requestAnimationFrame(() => {
|
||||
input.setSelectionRange(length, length);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FormControl id='password'>
|
||||
<Flex justify='space-between'>
|
||||
<FormLabel>Password</FormLabel>
|
||||
</Flex>
|
||||
<InputGroup>
|
||||
<InputLeftElement>
|
||||
<Icon as={MdVpnKey} />
|
||||
</InputLeftElement>
|
||||
<InputRightElement>
|
||||
<IconButton
|
||||
bg='transparent !important'
|
||||
variant='ghost'
|
||||
aria-label={isOpen ? 'Mask password' : 'Reveal password'}
|
||||
icon={isOpen ? <HiEyeOff /> : <HiEye />}
|
||||
onClick={onClickReveal}
|
||||
/>
|
||||
</InputRightElement>
|
||||
<Input
|
||||
ref={mergeRef}
|
||||
name='password'
|
||||
type={isOpen ? 'text' : 'password'}
|
||||
autoComplete='current-password'
|
||||
required
|
||||
{...other}
|
||||
/>
|
||||
</InputGroup>
|
||||
{errorMessage && (
|
||||
<Text as='small' color='red.500'>
|
||||
{errorMessage}
|
||||
</Text>
|
||||
)}
|
||||
</FormControl>
|
||||
);
|
||||
});
|
||||
|
||||
PasswordField.displayName = 'PasswordField';
|
||||
@ -60,8 +60,8 @@ const FileSelect: React.FC<FileSelectPropsType> = ({ onChange, file, supportedFo
|
||||
</Text>
|
||||
</Text>
|
||||
) : (
|
||||
<Text fontWeight='normal' color='gray'>
|
||||
Choose a file...
|
||||
<Text fontWeight='normal' color='gray.500'>
|
||||
Select new file
|
||||
</Text>
|
||||
)}
|
||||
{supportedFormats && (
|
||||
|
||||
144
src/components/FilesUploader/FilesUploader.tsx
Normal file
144
src/components/FilesUploader/FilesUploader.tsx
Normal file
@ -0,0 +1,144 @@
|
||||
import React, { useState } from 'react';
|
||||
import * as helpers from '@educt/helpers';
|
||||
import { Stack, Flex, Box, Icon, Text, Input, IconButton, Button, useColorModeValue } from '@chakra-ui/react';
|
||||
import { MdAttachFile, MdOutlineFilePresent } from 'react-icons/md';
|
||||
import { CloseIcon } from '@chakra-ui/icons';
|
||||
|
||||
/**
|
||||
* Hooks
|
||||
*/
|
||||
import { useRef } from 'react';
|
||||
import { FileSupportedFormatsEnum } from '@educt/enums';
|
||||
import { AttachmentFileType } from '@educt/types';
|
||||
|
||||
type FilesUploaderPropsType = {
|
||||
onChange: (files: File[]) => void;
|
||||
files?: File[] | undefined;
|
||||
preloadedFiles?: AttachmentFileType[] | undefined;
|
||||
};
|
||||
|
||||
const FileContent: React.FC<{ name: string; size: number }> = ({ name, size }) => (
|
||||
<Flex alignItems={'center'}>
|
||||
<Icon as={MdOutlineFilePresent} mr='2' h={4} w={4} />
|
||||
<Text overflow={'hidden'} fontSize={'sm'} fontWeight={'medium'} lineHeight={'1rem'}>
|
||||
{name}
|
||||
<br />
|
||||
<Text as='small' color='gray.500'>
|
||||
{helpers.transformBytes(size)}
|
||||
</Text>
|
||||
</Text>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
const FileWrapper: React.FC = ({ children }) => (
|
||||
<Flex
|
||||
alignItems={'center'}
|
||||
justifyContent={'space-between'}
|
||||
p='3'
|
||||
bg={useColorModeValue('gray.50', 'gray.700')}
|
||||
borderRadius={'lg'}
|
||||
>
|
||||
{children}
|
||||
</Flex>
|
||||
);
|
||||
|
||||
const FilesUploader: React.FC<FilesUploaderPropsType> = ({ onChange, files, preloadedFiles }) => {
|
||||
const [preloaded, setPreloaded] = useState<AttachmentFileType[]>(preloadedFiles || []);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const onSelectFileHandler = () => {
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.click();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Detach file
|
||||
*/
|
||||
const onRemoveSelectedFileHandler = (selectedFile: File) => () => {
|
||||
onChange([...Array.from(files?.filter(file => file.name !== selectedFile.name) || [])]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Attach file
|
||||
*/
|
||||
const onChangeFileHandler = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (e.target.files && e.target.files.length !== 0) {
|
||||
const isAlreadySelected = (files || []).find(selectedFile =>
|
||||
Array.from(e.target.files || []).some(file => file.name === selectedFile.name)
|
||||
);
|
||||
|
||||
if (isAlreadySelected) return;
|
||||
onChange([...Array.from(files || []), ...e.target.files]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Input
|
||||
type='file'
|
||||
ref={fileInputRef}
|
||||
onChange={onChangeFileHandler}
|
||||
display='none'
|
||||
multiple
|
||||
accept={Object.keys(FileSupportedFormatsEnum)
|
||||
.map(ext => `.${ext.toLowerCase()}`)
|
||||
.join(', ')}
|
||||
/>
|
||||
{preloadedFiles && files?.length === 0 && (
|
||||
<Stack>
|
||||
{preloaded.map((file, index) => (
|
||||
<FileWrapper key={index}>
|
||||
<FileContent name={file.name} size={file.size} />
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
setPreloaded(prev => prev.filter(prevFile => prevFile.size !== file.size));
|
||||
onChange([]);
|
||||
}}
|
||||
aria-label='remove'
|
||||
variant={'unstyled'}
|
||||
size='xs'
|
||||
icon={<CloseIcon />}
|
||||
/>
|
||||
</FileWrapper>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
{files && (
|
||||
<Stack>
|
||||
{files.map((file, index) => (
|
||||
<FileWrapper key={index}>
|
||||
<FileContent name={file.name} size={file.size} />
|
||||
<IconButton
|
||||
onClick={onRemoveSelectedFileHandler(file)}
|
||||
aria-label='remove'
|
||||
variant={'unstyled'}
|
||||
size='xs'
|
||||
icon={<CloseIcon />}
|
||||
/>
|
||||
</FileWrapper>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
{files?.length === 0 && preloaded?.length === 0 && (
|
||||
<Text color={'gray.500'} userSelect={'none'} fontSize={'sm'} textAlign={'center'}>
|
||||
No file selected
|
||||
</Text>
|
||||
)}
|
||||
<Button
|
||||
onClick={onSelectFileHandler}
|
||||
float={'right'}
|
||||
leftIcon={<MdAttachFile />}
|
||||
variant={'outline'}
|
||||
size='sm'
|
||||
mt='3'
|
||||
>
|
||||
Select files
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilesUploader;
|
||||
3
src/components/FilesUploader/index.ts
Normal file
3
src/components/FilesUploader/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import FilesUploader from './FilesUploader';
|
||||
|
||||
export default FilesUploader;
|
||||
@ -19,15 +19,19 @@ import FileSelect from '@educt/components/FileSelect';
|
||||
/**
|
||||
* Hooks
|
||||
*/
|
||||
import { useToast } from '@chakra-ui/toast';
|
||||
import { useRootStore } from '@educt/hooks/useRootStore';
|
||||
import { useErrorHandler } from 'react-error-boundary';
|
||||
|
||||
/**
|
||||
* Services
|
||||
*/
|
||||
import { CategoryServiceInstance, UserServiceInstance } from '@educt/services';
|
||||
|
||||
export type InputFields = {
|
||||
title: string;
|
||||
image?: File | undefined;
|
||||
category_id: string;
|
||||
description: string;
|
||||
education_description: string | null;
|
||||
teacher_id: string;
|
||||
};
|
||||
|
||||
@ -46,30 +50,21 @@ const CourseForm: React.FC<CourseFormPropsType> = ({ onSubmit, buttonLabel, isLo
|
||||
formState: { errors, isDirty },
|
||||
} = reactHookForm;
|
||||
|
||||
const {
|
||||
userStore: { userService },
|
||||
categoryStore: { categoryService },
|
||||
} = useRootStore();
|
||||
const handleError = useErrorHandler();
|
||||
const toast = useToast();
|
||||
|
||||
/**
|
||||
* Load users into select field
|
||||
*/
|
||||
const loadUsersOptions = async (): Promise<OptionType[] | undefined> => {
|
||||
try {
|
||||
const users = await userService.fetchAll({ limit: 12, role: UserRoleEnum.TEACHER });
|
||||
const users = await UserServiceInstance.fetchAll({ limit: 12, role: UserRoleEnum.TEACHER });
|
||||
|
||||
return users.data.map(user => ({
|
||||
label: user.fullname,
|
||||
value: user.id,
|
||||
}));
|
||||
} catch (error: any) {
|
||||
if (error.response) {
|
||||
toast({ title: 'Error' });
|
||||
} else {
|
||||
handleError(error);
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
};
|
||||
|
||||
@ -78,22 +73,19 @@ const CourseForm: React.FC<CourseFormPropsType> = ({ onSubmit, buttonLabel, isLo
|
||||
*/
|
||||
const loadCategoriesOptions = async (): Promise<OptionType[] | undefined> => {
|
||||
try {
|
||||
const categories = await categoryService.fetchAll();
|
||||
const categories = await CategoryServiceInstance.fetchAll();
|
||||
return categories.data.map(category => ({
|
||||
label: category.title,
|
||||
value: category.id,
|
||||
}));
|
||||
} catch (error: any) {
|
||||
if (error.response) {
|
||||
toast({ title: 'Error' });
|
||||
} else {
|
||||
handleError(error);
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
};
|
||||
|
||||
const watchTitle = watch('title');
|
||||
const watchDescription = watch('description');
|
||||
const watchEducationDescription = watch('education_description');
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit}>
|
||||
@ -102,14 +94,11 @@ const CourseForm: React.FC<CourseFormPropsType> = ({ onSubmit, buttonLabel, isLo
|
||||
<FormLabel>Name</FormLabel>
|
||||
<InputGroup>
|
||||
<Input pr='60px' size='md' placeholder='Course name' type='text' {...register('title')} />
|
||||
<InputRightElement
|
||||
mr='2'
|
||||
children={
|
||||
<Text as='small' color={!!errors.title || watchTitle?.length > 90 ? 'red.500' : 'gray.500'}>
|
||||
{watchTitle?.length || 0}/90
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
<InputRightElement mr='2'>
|
||||
<Text as='small' color={!!errors.title || watchTitle?.length > 90 ? 'red.500' : 'gray.500'}>
|
||||
{watchTitle?.length || 0}/90
|
||||
</Text>
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
<FormHelperText>e.g Web Development in Javascript</FormHelperText>
|
||||
<Text as='small' color='red.500'>
|
||||
@ -130,6 +119,26 @@ const CourseForm: React.FC<CourseFormPropsType> = ({ onSubmit, buttonLabel, isLo
|
||||
</Flex>
|
||||
</FormControl>
|
||||
|
||||
<FormControl id='education_description' isInvalid={!!errors.education_description}>
|
||||
<FormLabel>What you'll learn section</FormLabel>
|
||||
<Textarea resize='none' minH='80px' {...register('education_description')} />
|
||||
<Flex justifyContent='space-between'>
|
||||
<Text as='small' color='red.500'>
|
||||
{errors.education_description?.message}
|
||||
</Text>
|
||||
{watchEducationDescription && (
|
||||
<Text
|
||||
as='small'
|
||||
color={
|
||||
!!errors.education_description || watchEducationDescription.length > 250 ? 'red.500' : 'gray.500'
|
||||
}
|
||||
>
|
||||
{watchEducationDescription.length || 0}/250
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</FormControl>
|
||||
|
||||
<Flex justifyContent='space-between' flexDirection={{ base: 'column', lg: 'row' }}>
|
||||
<Box flexGrow={1} flexBasis='0' mr={{ base: '0', lg: '7' }}>
|
||||
<Stack spacing='5'>
|
||||
|
||||
@ -9,6 +9,7 @@ const CreateCourseSchema = yup.object().shape({
|
||||
.string()
|
||||
.required('Description field name is required')
|
||||
.max(250, 'Description must be at most 250 characters'),
|
||||
education_description: yup.string().max(250, 'This section must be at most 250 characters').nullable(),
|
||||
image: yup
|
||||
.mixed()
|
||||
.test('fileSize', 'File size is too large', (value: File) => {
|
||||
|
||||
@ -17,62 +17,38 @@ import CourseForm from './CourseForm';
|
||||
/**
|
||||
* Hooks
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useHistory } from 'react-router';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useToast } from '@chakra-ui/toast';
|
||||
import { useRootStore } from '@educt/hooks/useRootStore';
|
||||
import useIsMountedRef from '@educt/hooks/useIsMountedRef';
|
||||
import { useErrorHandler } from 'react-error-boundary';
|
||||
import { useCreateCourse } from '@educt/hooks/queries';
|
||||
|
||||
/**
|
||||
* Schema
|
||||
*/
|
||||
import CourseFormSchema from './CourseForm.validator';
|
||||
|
||||
type CreateFormCoursePropsType = {};
|
||||
|
||||
const CreateFormCourse: React.FC<CreateFormCoursePropsType> = () => {
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const {
|
||||
courseStore: { courseService },
|
||||
} = useRootStore();
|
||||
const CreateFormCourse: React.FC = () => {
|
||||
const form = useForm<InputFields>({ resolver: yupResolver(CourseFormSchema) });
|
||||
const isMountedRef = useIsMountedRef();
|
||||
const { createCourse, isLoading } = useCreateCourse();
|
||||
const history = useHistory();
|
||||
const toast = useToast();
|
||||
const handleError = useErrorHandler();
|
||||
|
||||
/**
|
||||
* Submit handler
|
||||
*/
|
||||
const onSubmit: SubmitHandler<InputFields> = async data => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const course = await courseService.create({
|
||||
const course = await createCourse({
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
education_description: data.education_description,
|
||||
teacher_id: data.teacher_id,
|
||||
category_id: data.category_id,
|
||||
image: data.image,
|
||||
status: CourseStatusEnum.DRAFT,
|
||||
});
|
||||
toast({ title: `Course successfully created.`, status: 'success' });
|
||||
history.push(`/courses/edit/${course.data.id}`);
|
||||
} catch (error: any) {
|
||||
if (error.response) {
|
||||
if (error.response.status === 422) {
|
||||
toast({ title: `${error.response.data.errors[0].message}`, status: 'error' });
|
||||
} else {
|
||||
toast({ title: `${error.message}`, status: 'error' });
|
||||
}
|
||||
} else {
|
||||
handleError(error);
|
||||
}
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
history.push(`/courses/edit/${course.id}`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@chakra-ui/layout';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { SubmitHandler } from 'react-hook-form';
|
||||
|
||||
/**
|
||||
* Types
|
||||
*/
|
||||
import { InputFields } from './CourseForm';
|
||||
import { SubmitHandler } from 'react-hook-form';
|
||||
|
||||
/**
|
||||
* Components
|
||||
@ -16,17 +16,14 @@ import CourseForm from './CourseForm';
|
||||
/**
|
||||
* Hooks
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useRootStore } from '@educt/hooks/useRootStore';
|
||||
import { useErrorHandler } from 'react-error-boundary';
|
||||
import { useToast } from '@chakra-ui/toast';
|
||||
|
||||
/**
|
||||
* Schema
|
||||
*/
|
||||
import CourseFormSchema from './CourseForm.validator';
|
||||
import { useUpdateCourse } from '@educt/hooks/queries';
|
||||
|
||||
type EditFormCoursePropsType = {
|
||||
defaultValues?:
|
||||
@ -35,28 +32,25 @@ type EditFormCoursePropsType = {
|
||||
image?: File | undefined;
|
||||
category_id?: string | undefined;
|
||||
description?: string | undefined;
|
||||
education_description?: string | null | undefined;
|
||||
teacher_id?: string | undefined;
|
||||
}
|
||||
| undefined;
|
||||
};
|
||||
|
||||
const EditFormCourse: React.FC<EditFormCoursePropsType> = ({ defaultValues }) => {
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const {
|
||||
courseStore: { courseService },
|
||||
} = useRootStore();
|
||||
const form = useForm<InputFields>({
|
||||
resolver: yupResolver(CourseFormSchema),
|
||||
defaultValues: {
|
||||
title: defaultValues?.title || '',
|
||||
description: defaultValues?.description || '',
|
||||
education_description: defaultValues?.education_description || '',
|
||||
category_id: defaultValues?.category_id || '',
|
||||
teacher_id: defaultValues?.teacher_id || '',
|
||||
image: undefined,
|
||||
},
|
||||
});
|
||||
const toast = useToast();
|
||||
const handleError = useErrorHandler();
|
||||
const { updateCourse, isLoading } = useUpdateCourse();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
|
||||
/**
|
||||
@ -64,34 +58,21 @@ const EditFormCourse: React.FC<EditFormCoursePropsType> = ({ defaultValues }) =>
|
||||
*/
|
||||
const onSubmit: SubmitHandler<InputFields> = async data => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const course = await courseService.update(id, {
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
teacher_id: data.teacher_id,
|
||||
category_id: data.category_id,
|
||||
image: data.image,
|
||||
});
|
||||
toast({ title: `Course updated.`, status: 'info' });
|
||||
const updatedCourse = await updateCourse(id, data);
|
||||
|
||||
/**
|
||||
* Update fields with new values
|
||||
*/
|
||||
form.reset({
|
||||
title: course.data.title,
|
||||
description: course.data.description,
|
||||
teacher_id: course.data.teacher.id,
|
||||
category_id: course.data.category.id,
|
||||
title: updatedCourse.title,
|
||||
description: updatedCourse.description,
|
||||
education_description: updatedCourse.education_description,
|
||||
teacher_id: updatedCourse.teacher.id,
|
||||
category_id: updatedCourse.category.id,
|
||||
image: undefined,
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (error.response) {
|
||||
toast({ title: `${error.message}`, status: 'error' });
|
||||
} else {
|
||||
handleError(error);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
46
src/components/Forms/LessonForm/CreateLessonForm.tsx
Normal file
46
src/components/Forms/LessonForm/CreateLessonForm.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { SubmitHandler, useForm } from 'react-hook-form';
|
||||
import type { InputFields } from './LessonForm';
|
||||
import { LessonForm } from '.';
|
||||
|
||||
/**
|
||||
* Validator
|
||||
*/
|
||||
import { CreateLessonFormSchema } from './LessonForm.validator';
|
||||
|
||||
/**
|
||||
* Hooks
|
||||
*/
|
||||
import { useCreateLesson } from '@educt/hooks/queries';
|
||||
import { useHistory, useParams } from 'react-router-dom';
|
||||
|
||||
const CreateLessonForm: React.FC = () => {
|
||||
const form = useForm<InputFields>({ resolver: yupResolver(CreateLessonFormSchema) });
|
||||
const { createLesson, isLoading } = useCreateLesson();
|
||||
const { id: course_id } = useParams<{ id: string }>();
|
||||
const history = useHistory();
|
||||
|
||||
const onSubmit: SubmitHandler<InputFields> = async data => {
|
||||
await createLesson({
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
duration: data.duration,
|
||||
video: data.video,
|
||||
materials: Array.from(data.materials || []),
|
||||
course_id,
|
||||
});
|
||||
history.push(`/courses/edit/${course_id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<LessonForm
|
||||
isLoading={isLoading}
|
||||
reactHookForm={{ ...form }}
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
buttonLabel='Create'
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateLessonForm;
|
||||
70
src/components/Forms/LessonForm/EditLessonForm.tsx
Normal file
70
src/components/Forms/LessonForm/EditLessonForm.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { SubmitHandler, useForm } from 'react-hook-form';
|
||||
import type { InputFields } from './LessonForm';
|
||||
import { ILesson } from '@educt/interfaces';
|
||||
import { LessonForm } from '.';
|
||||
|
||||
/**
|
||||
* Validator
|
||||
*/
|
||||
import { EditLessonFormSchema } from './LessonForm.validator';
|
||||
|
||||
/**
|
||||
* Hooks
|
||||
*/
|
||||
import { useHistory, useParams } from 'react-router-dom';
|
||||
import { useUpdateLesson } from '@educt/hooks/queries';
|
||||
|
||||
type EditLessonFormPropsType = {
|
||||
lesson: ILesson;
|
||||
};
|
||||
|
||||
const EditLessonForm: React.FC<EditLessonFormPropsType> = ({ lesson }) => {
|
||||
const form = useForm<InputFields>({
|
||||
resolver: yupResolver(EditLessonFormSchema),
|
||||
defaultValues: {
|
||||
title: lesson.title,
|
||||
description: lesson.description,
|
||||
duration: lesson.duration,
|
||||
video: undefined,
|
||||
materials: undefined,
|
||||
},
|
||||
});
|
||||
const { updateLesson, isLoading } = useUpdateLesson();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const history = useHistory();
|
||||
|
||||
const onSubmit: SubmitHandler<InputFields> = async data => {
|
||||
await updateLesson(id, {
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
video: data.video,
|
||||
duration: data.duration,
|
||||
materials: data.materials?.length !== 0 ? Array.from(data.materials || []) : null,
|
||||
});
|
||||
|
||||
history.push(`/lesson/${id}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<LessonForm
|
||||
isLoading={isLoading}
|
||||
reactHookForm={{ ...form }}
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
buttonLabel='Save'
|
||||
preloadedVideo={lesson.video || undefined}
|
||||
preloadedMaterials={lesson.materials.map(material => {
|
||||
return {
|
||||
name: material.client_name,
|
||||
extname: material.ext,
|
||||
size: material.size,
|
||||
url: `materials/${material.name}`,
|
||||
mimeType: '',
|
||||
};
|
||||
})}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditLessonForm;
|
||||
149
src/components/Forms/LessonForm/LessonForm.tsx
Normal file
149
src/components/Forms/LessonForm/LessonForm.tsx
Normal file
@ -0,0 +1,149 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
FormControl,
|
||||
FormHelperText,
|
||||
FormLabel,
|
||||
Text,
|
||||
Input,
|
||||
InputGroup,
|
||||
Stack,
|
||||
Box,
|
||||
Button,
|
||||
Textarea,
|
||||
Flex,
|
||||
Divider,
|
||||
} from '@chakra-ui/react';
|
||||
import { Controller, UseFormReturn } from 'react-hook-form';
|
||||
import VideoUploader from '@educt/components/VideoUploader';
|
||||
import FilesUploader from '@educt/components/FilesUploader';
|
||||
import { FileSupportedFormatsEnum, VideoSupportedFormatsEnum } from '@educt/enums';
|
||||
import type { AttachmentFileType, LessonVideoType } from '@educt/types';
|
||||
|
||||
export type InputFields = {
|
||||
title: string;
|
||||
description: string;
|
||||
duration: string;
|
||||
video: File;
|
||||
materials?: FileList | undefined;
|
||||
};
|
||||
|
||||
export type LessonFormPropsType = {
|
||||
onSubmit: () => Promise<void>;
|
||||
buttonLabel: string;
|
||||
isLoading: boolean;
|
||||
reactHookForm: UseFormReturn<InputFields>;
|
||||
preloadedVideo?: LessonVideoType | undefined;
|
||||
preloadedMaterials?: AttachmentFileType[] | undefined;
|
||||
};
|
||||
|
||||
const LessonForm: React.FC<LessonFormPropsType> = ({
|
||||
onSubmit,
|
||||
buttonLabel,
|
||||
isLoading,
|
||||
reactHookForm,
|
||||
preloadedVideo,
|
||||
preloadedMaterials,
|
||||
}) => {
|
||||
const {
|
||||
control,
|
||||
register,
|
||||
formState: { errors, isDirty },
|
||||
} = reactHookForm;
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit}>
|
||||
<Stack spacing={'5'}>
|
||||
<FormControl isRequired id='title' isInvalid={!!errors.title}>
|
||||
<FormLabel>Title</FormLabel>
|
||||
<InputGroup>
|
||||
<Input placeholder='Lesson name' type='text' {...register('title')} />
|
||||
</InputGroup>
|
||||
<Text as='small' color='red.500'>
|
||||
{errors.title?.message}
|
||||
</Text>
|
||||
<FormHelperText>e.g Introduction</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<FormControl isRequired id='description' isInvalid={!!errors.description}>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<Textarea resize='none' minH='150px' placeholder='Lesson description...' {...register('description')} />
|
||||
<Flex justifyContent='space-between'>
|
||||
<Text as='small' color='red.500'>
|
||||
{errors.description?.message}
|
||||
</Text>
|
||||
</Flex>
|
||||
</FormControl>
|
||||
|
||||
<FormControl isRequired id='video'>
|
||||
<FormLabel>Video</FormLabel>
|
||||
<Controller
|
||||
control={control}
|
||||
name='video'
|
||||
render={({ field: { onChange, value: file } }) => (
|
||||
<VideoUploader onChange={file => onChange(file)} file={file} preloadedVideoUrl={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'>
|
||||
<FormLabel>Video duration</FormLabel>
|
||||
<Input type='time' defaultValue={'00:30:00'} step='1' {...register('duration')} />
|
||||
<Text as='small' color='red.500'>
|
||||
{errors.duration?.message}
|
||||
</Text>
|
||||
<FormHelperText>hours:minutes:seconds</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Materials</FormLabel>
|
||||
<Controller
|
||||
control={control}
|
||||
name='materials'
|
||||
render={({ field: { onChange, value: files } }) => (
|
||||
<FilesUploader
|
||||
onChange={files => onChange(files)}
|
||||
files={Array.from(files || [])}
|
||||
preloadedFiles={preloadedMaterials}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Text as='small' color='red.500'>
|
||||
{errors.materials?.message}
|
||||
</Text>
|
||||
<FormHelperText>
|
||||
Supported formats:{' '}
|
||||
{Object.keys(FileSupportedFormatsEnum)
|
||||
.map(ext => `${ext.toLowerCase()}`)
|
||||
.join(', ')}
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box mt='5'>
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
isDisabled={!isDirty}
|
||||
loadingText='Saving...'
|
||||
type='submit'
|
||||
colorScheme='blue'
|
||||
variant='outline'
|
||||
>
|
||||
{buttonLabel}
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default LessonForm;
|
||||
41
src/components/Forms/LessonForm/LessonForm.validator.ts
Normal file
41
src/components/Forms/LessonForm/LessonForm.validator.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import * as helpers from '@educt/helpers';
|
||||
import yup from '@educt/schema';
|
||||
|
||||
const VIDEO_FILE_SIZE = 5000 * 1024 * 1024;
|
||||
const MATERIAL_FILE_SIZE = 100000 * 1024;
|
||||
|
||||
const CreateLessonFormSchema = yup
|
||||
.object({
|
||||
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')
|
||||
.max(250, 'Description must be at most 250 characters'),
|
||||
duration: yup.string().required('Duration field name is required'),
|
||||
video: yup
|
||||
.mixed<File>()
|
||||
.test('fileSize', `File size is too large, maximum size is ${helpers.transformBytes(VIDEO_FILE_SIZE)}`, value =>
|
||||
!value ? true : value.size <= VIDEO_FILE_SIZE
|
||||
)
|
||||
.required('Video field name is required'),
|
||||
materials: yup
|
||||
.mixed<File[]>()
|
||||
.test(
|
||||
'fileSize',
|
||||
`File size is too large, maximum size is ${helpers.transformBytes(MATERIAL_FILE_SIZE)}`,
|
||||
values => (!values ? true : values.every(value => value.size <= MATERIAL_FILE_SIZE))
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.required();
|
||||
|
||||
const EditLessonFormSchema = CreateLessonFormSchema.shape({
|
||||
video: yup
|
||||
.mixed<File>()
|
||||
.test('fileSize', `File size is too large, maximum size is ${helpers.transformBytes(VIDEO_FILE_SIZE)}`, value =>
|
||||
!value ? true : value.size <= VIDEO_FILE_SIZE
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export { CreateLessonFormSchema, EditLessonFormSchema };
|
||||
3
src/components/Forms/LessonForm/index.ts
Normal file
3
src/components/Forms/LessonForm/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import LessonForm from './LessonForm';
|
||||
|
||||
export { LessonForm };
|
||||
@ -1,68 +0,0 @@
|
||||
import React from 'react';
|
||||
import { observer } from 'mobx-react';
|
||||
import { Flex, Box, Heading, useColorMode } from '@chakra-ui/react';
|
||||
import { MdSchool } from 'react-icons/md';
|
||||
import config from '@educt/config';
|
||||
|
||||
/**
|
||||
* Components
|
||||
*/
|
||||
import NavMobile from '@educt/components/Nav/Mobile';
|
||||
import ColorModeSwitcher from '@educt/components/ColorModeSwitcher';
|
||||
import UserInfo from './UserInfo';
|
||||
import UserNotifications from './UserNotifications';
|
||||
|
||||
/**
|
||||
* Hooks
|
||||
*/
|
||||
import { useMediaQuery } from '@chakra-ui/media-query';
|
||||
|
||||
const Header: React.FC = () => {
|
||||
const [isDesktop] = useMediaQuery('(min-width: 992px)');
|
||||
const { colorMode } = useColorMode();
|
||||
|
||||
return (
|
||||
<Box
|
||||
as='header'
|
||||
position='fixed'
|
||||
top={0}
|
||||
left={0}
|
||||
right={0}
|
||||
// borderBottomWidth={1}
|
||||
// h='43px'
|
||||
boxShadow='md'
|
||||
backgroundColor={colorMode === 'dark' ? 'gray.800' : 'white'}
|
||||
zIndex={4}
|
||||
>
|
||||
<Flex
|
||||
justifyContent='space-between'
|
||||
width='100%'
|
||||
height='100%'
|
||||
alignItems='center'
|
||||
padding='5px 1.5rem 5px 1.5rem'
|
||||
>
|
||||
<Flex alignItems='center'>
|
||||
<Box mr='4'>
|
||||
<Box as={MdSchool} color='blue.500' size='36px' />
|
||||
</Box>
|
||||
<Box>
|
||||
<Heading as='p' fontSize='2xl' lineHeight='1'>
|
||||
{config.metaData.appName}
|
||||
</Heading>
|
||||
</Box>
|
||||
</Flex>
|
||||
{isDesktop ? (
|
||||
<Flex>
|
||||
<ColorModeSwitcher />
|
||||
<UserNotifications />
|
||||
<UserInfo />
|
||||
</Flex>
|
||||
) : (
|
||||
<NavMobile />
|
||||
)}
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default observer(Header);
|
||||
14
src/components/Header/Logo.tsx
Normal file
14
src/components/Header/Logo.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import { Link as ReactRouterLink } from 'react-router-dom';
|
||||
import { Box, BoxProps, Link } from '@chakra-ui/react';
|
||||
import LogoIcon from '@educt/components/Logo';
|
||||
|
||||
const Logo: React.FC<BoxProps> = props => (
|
||||
<Box {...props}>
|
||||
<Link as={ReactRouterLink} to='/' _focus={{ outline: 'none' }}>
|
||||
<LogoIcon h='10' />
|
||||
</Link>
|
||||
</Box>
|
||||
);
|
||||
|
||||
export { Logo };
|
||||
136
src/components/Header/Notifications.tsx
Normal file
136
src/components/Header/Notifications.tsx
Normal file
@ -0,0 +1,136 @@
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import moment from 'moment';
|
||||
import {
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuList,
|
||||
MenuItem,
|
||||
MenuGroup,
|
||||
IconButton,
|
||||
MenuDivider,
|
||||
Flex,
|
||||
Text,
|
||||
useDisclosure,
|
||||
} from '@chakra-ui/react';
|
||||
import { Skeleton } from '@chakra-ui/skeleton';
|
||||
import { MdNotifications, MdOutlineMessage } from 'react-icons/md';
|
||||
import { observer } from 'mobx-react';
|
||||
|
||||
/**
|
||||
* Hooks
|
||||
*/
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { useRootStore } from '@educt/hooks/useRootStore';
|
||||
import { SocketContext } from '@educt/contexts';
|
||||
import { runInAction } from 'mobx';
|
||||
|
||||
const Notifications: React.FC = observer(() => {
|
||||
const {
|
||||
userStore: { me, isLoading },
|
||||
} = useRootStore();
|
||||
const history = useHistory();
|
||||
const { socket } = useContext(SocketContext);
|
||||
const { onOpen, onClose, isOpen } = useDisclosure();
|
||||
const [count, setCount] = useState<number>(0);
|
||||
|
||||
/**
|
||||
* Load count of notifications
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (me) {
|
||||
setCount(me.notifications.length);
|
||||
}
|
||||
}, [me, me?.notifications.length]);
|
||||
|
||||
/**
|
||||
* Mark notifications as read
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (isOpen && count > 0 && me) {
|
||||
const messages = me.notifications.filter(n => n.type === 'MESSAGE');
|
||||
socket?.emit('notification:read', {
|
||||
userId: me.id,
|
||||
ids: messages.map(n => n.id),
|
||||
});
|
||||
setCount(0);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
/**
|
||||
* Delete notifications with type `message` when on message page
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (window.location.pathname.includes('messages') && me) {
|
||||
socket?.emit('notification:read', {
|
||||
userId: me.id,
|
||||
ids: me.notifications.filter(n => n.type === 'MESSAGE').map(n => n.id),
|
||||
});
|
||||
|
||||
setCount(0);
|
||||
runInAction(() => {
|
||||
me.notifications = me.notifications.filter(n => n.type !== 'MESSAGE');
|
||||
});
|
||||
}
|
||||
}, [me]);
|
||||
|
||||
if (me === null) {
|
||||
return <Skeleton width='50px' height='40px' mr={3} ml={3} borderRadius='md' />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter types of notifications
|
||||
*/
|
||||
const messages = me.notifications.filter(notification => notification.type === 'MESSAGE');
|
||||
|
||||
return (
|
||||
<Menu isOpen={isOpen} onClose={onClose} onOpen={onOpen} isLazy>
|
||||
<MenuButton
|
||||
as={IconButton}
|
||||
mx='2'
|
||||
aria-label='Show my notifications'
|
||||
icon={<MdNotifications />}
|
||||
_after={{
|
||||
content: `"${count && count > 0 && count}"`,
|
||||
display: `${!isLoading ? (count && count > 0 ? 'block' : 'none') : 'none'}`,
|
||||
position: 'absolute',
|
||||
top: '5px',
|
||||
right: '5px',
|
||||
background: '#E53E3E',
|
||||
color: '#fff',
|
||||
fontSize: '10px',
|
||||
width: '15px',
|
||||
height: '15px',
|
||||
borderRadius: '100%',
|
||||
}}
|
||||
/>
|
||||
<MenuList minWidth='300px'>
|
||||
<MenuGroup title={count && count > 0 ? `${count} new notifications` : 'Notifications'}>
|
||||
<MenuDivider />
|
||||
|
||||
{/* MESSAGES */}
|
||||
{messages.length !== 0 && (
|
||||
<MenuItem onClick={() => history.push('/messages')} py='3' icon={<MdOutlineMessage />}>
|
||||
<Flex flexDir='column' fontSize={'sm'}>
|
||||
<Text as='span' mr='3'>
|
||||
You have <b>{messages.length}</b> new private messages
|
||||
</Text>
|
||||
<Text as='span' fontSize={'xs'} color='gray.500' justifySelf='flex-start'>
|
||||
{moment(messages[0].time).fromNow()}
|
||||
</Text>
|
||||
</Flex>
|
||||
</MenuItem>
|
||||
)}
|
||||
|
||||
{/* EMPTY NOTIFICATIONS */}
|
||||
{me.notifications.length === 0 && (
|
||||
<Text fontSize={'sm'} color='gray.500' textAlign='center' py='3' userSelect={'none'}>
|
||||
You have no notifications
|
||||
</Text>
|
||||
)}
|
||||
</MenuGroup>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
);
|
||||
});
|
||||
|
||||
export { Notifications };
|
||||
@ -22,13 +22,15 @@ import UserBadge from '@educt/components/UserBadge';
|
||||
/**
|
||||
* Hooks
|
||||
*/
|
||||
import useLogoutQuery from '@educt/hooks/useLogoutQuery';
|
||||
import { useLogout } from '@educt/hooks/queries';
|
||||
|
||||
const UserInfo: React.FC = () => {
|
||||
const { userStore } = useRootStore();
|
||||
const { logout } = useLogoutQuery();
|
||||
const Profile: React.FC = observer(() => {
|
||||
const {
|
||||
userStore: { me },
|
||||
} = useRootStore();
|
||||
const { logout } = useLogout();
|
||||
|
||||
if (userStore.me === null) {
|
||||
if (me === null) {
|
||||
return <Skeleton width='260px' height='40px' borderRadius='md' />;
|
||||
}
|
||||
|
||||
@ -37,15 +39,15 @@ const UserInfo: React.FC = () => {
|
||||
<Menu>
|
||||
<MenuButton as={Button} pr={6}>
|
||||
<Flex alignItems='center'>
|
||||
<Avatar size='sm' name={userStore.me.fullname} marginRight={3} />
|
||||
<Avatar size='sm' name={me.fullname} marginRight={3} />
|
||||
<Text as='span' mr={2}>
|
||||
{userStore.me.fullname}
|
||||
{me.fullname}
|
||||
</Text>
|
||||
<UserBadge roles={userStore.me.roles} />
|
||||
<UserBadge roles={me.roles} />
|
||||
</Flex>
|
||||
</MenuButton>
|
||||
<MenuList mr='1rem'>
|
||||
<MenuGroup title={userStore.me.fullname}>
|
||||
<MenuGroup title={me.fullname}>
|
||||
<MenuDivider />
|
||||
<Link to='/profile'>
|
||||
<MenuItem>
|
||||
@ -62,6 +64,6 @@ const UserInfo: React.FC = () => {
|
||||
</Menu>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export default observer(UserInfo);
|
||||
export { Profile };
|
||||
@ -1,42 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@chakra-ui/layout';
|
||||
import { Skeleton } from '@chakra-ui/skeleton';
|
||||
import { Button } from '@chakra-ui/button';
|
||||
import { MdNotifications } from 'react-icons/md';
|
||||
import { useRootStore } from '@educt/hooks/useRootStore';
|
||||
import { observer } from 'mobx-react';
|
||||
|
||||
const UserNotifications: React.FC = () => {
|
||||
const { userStore } = useRootStore();
|
||||
|
||||
if (userStore.me === null) {
|
||||
return <Skeleton width='50px' height='40px' mr={3} ml={3} borderRadius='md' />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
mr={3}
|
||||
ml={3}
|
||||
variant='solid'
|
||||
outline='none'
|
||||
border='none'
|
||||
_after={{
|
||||
content: '"2"',
|
||||
display: 'block',
|
||||
position: 'absolute',
|
||||
top: '8px',
|
||||
right: '10px',
|
||||
background: '#E53E3E',
|
||||
color: '#fff',
|
||||
fontSize: '11px',
|
||||
width: '15px',
|
||||
height: '15px',
|
||||
borderRadius: '100%',
|
||||
}}
|
||||
>
|
||||
<Box as={MdNotifications} boxSize='21px' />
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default observer(UserNotifications);
|
||||
@ -1,3 +0,0 @@
|
||||
import Header from './Header';
|
||||
|
||||
export default Header;
|
||||
48
src/components/Header/index.tsx
Normal file
48
src/components/Header/index.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { Flex, Box, BoxProps } from '@chakra-ui/react';
|
||||
import { Logo } from './Logo';
|
||||
import { Profile } from './Profile';
|
||||
import { Notifications } from './Notifications';
|
||||
import ColorModeSwitcher from '@educt/components/ColorModeSwitcher';
|
||||
import NavMobile from '@educt/components/Nav/Mobile';
|
||||
import type { ColorModeSwitcherProps } from '../ColorModeSwitcher/ColorModeSwitcher';
|
||||
|
||||
/**
|
||||
* Hooks
|
||||
*/
|
||||
import { useColorModeValue, useMediaQuery } from '@chakra-ui/react';
|
||||
|
||||
interface IHeaderComposition {
|
||||
Logo: React.FC<BoxProps>;
|
||||
ThemeSwitcher: React.FC<ColorModeSwitcherProps>;
|
||||
Notifications: React.FC;
|
||||
Profile: React.FC;
|
||||
}
|
||||
|
||||
const Header: React.FC<BoxProps> & IHeaderComposition = ({ children }) => {
|
||||
const [isDesktop] = useMediaQuery('(min-width: 992px)');
|
||||
|
||||
return (
|
||||
<Box
|
||||
as={'header'}
|
||||
position={'fixed'}
|
||||
top={0}
|
||||
left={0}
|
||||
right={0}
|
||||
borderBottomWidth={'1px'}
|
||||
backgroundColor={useColorModeValue('white', 'gray.800')}
|
||||
zIndex={4}
|
||||
>
|
||||
<Flex justifyContent={'space-between'} w={'full'} height={'full'} alignItems={'center'} py={'2'} px={'6'}>
|
||||
{isDesktop ? children : <NavMobile />}
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
Header.Logo = Logo;
|
||||
Header.ThemeSwitcher = props => <ColorModeSwitcher {...props} />;
|
||||
Header.Notifications = Notifications;
|
||||
Header.Profile = Profile;
|
||||
|
||||
export default Header;
|
||||
@ -1,7 +1,6 @@
|
||||
import React, { Suspense } from 'react';
|
||||
import { observer } from 'mobx-react';
|
||||
import { Redirect } from 'react-router';
|
||||
import { useErrorHandler } from 'react-error-boundary';
|
||||
import { userContainRoles } from '@educt/helpers';
|
||||
import { Flex, Box } from '@chakra-ui/react';
|
||||
|
||||
@ -20,16 +19,25 @@ import NavDesktop from '@educt/components/Nav/Desktop';
|
||||
/**
|
||||
* Hooks
|
||||
*/
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useContext } from 'react';
|
||||
import { useErrorHandler } from 'react-error-boundary';
|
||||
import { useRootStore } from '@educt/hooks/useRootStore';
|
||||
|
||||
type LayoutPropsType = { roles: UserRoleEnum[] | undefined };
|
||||
/**
|
||||
* Contexts
|
||||
*/
|
||||
import { SocketContext } from '@educt/contexts';
|
||||
|
||||
interface ILayoutProps {
|
||||
roles?: UserRoleEnum[] | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the current page using layout
|
||||
*/
|
||||
const Layout: React.FC<LayoutPropsType> = ({ children, roles }) => {
|
||||
const Layout: React.FC<ILayoutProps> = ({ children, roles }) => {
|
||||
const { userStore } = useRootStore();
|
||||
const { socket } = useContext(SocketContext);
|
||||
const handleError = useErrorHandler();
|
||||
|
||||
/**
|
||||
@ -39,12 +47,34 @@ const Layout: React.FC<LayoutPropsType> = ({ children, roles }) => {
|
||||
userStore.loadCurrentUserData().catch(error => handleError(error));
|
||||
}, [handleError, userStore]);
|
||||
|
||||
/**
|
||||
* Connect to websocket server
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!socket?.connected) {
|
||||
if (socket) {
|
||||
const sessionId = window.localStorage.getItem('sessionId');
|
||||
if (sessionId) {
|
||||
socket.auth = { sessionId };
|
||||
}
|
||||
socket.connect();
|
||||
}
|
||||
}
|
||||
}, [socket]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<Header>
|
||||
<Header.Logo />
|
||||
<Flex>
|
||||
<Header.ThemeSwitcher />
|
||||
<Header.Notifications />
|
||||
<Header.Profile />
|
||||
</Flex>
|
||||
</Header>
|
||||
<NavDesktop />
|
||||
<Flex minH='100vh' justifyContent='center'>
|
||||
<Box as='main' flex='1 0' maxW='85rem' ml={{ base: '0', lg: '20rem' }} mt='7rem' mb='1rem' pr='2rem' pl='2rem'>
|
||||
<Box as='main' flex='1 0' ml={{ base: '0', lg: '20rem' }}>
|
||||
<Suspense fallback={<LoadingPage />}>
|
||||
{(() => {
|
||||
/**
|
||||
@ -61,12 +91,12 @@ const Layout: React.FC<LayoutPropsType> = ({ children, roles }) => {
|
||||
if (!userContainRoles(me.roles, roles)) return <Redirect to='/404' />;
|
||||
|
||||
return children;
|
||||
} else {
|
||||
/**
|
||||
* Return children if "roles" prop is not defined
|
||||
*/
|
||||
return children;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return children if "roles" prop is not defined
|
||||
*/
|
||||
return children;
|
||||
})()}
|
||||
</Suspense>
|
||||
</Box>
|
||||
|
||||
@ -3,7 +3,7 @@ import React from 'react';
|
||||
|
||||
const LoadingPage: React.FC = () => {
|
||||
return (
|
||||
<Flex justifyContent='center' mt='40'>
|
||||
<Flex justifyContent='center' mt='20rem'>
|
||||
<Box mt='auto' mb='auto' textAlign='center'>
|
||||
<Spinner thickness='4px' speed='0.55s' emptyColor='gray.200' color='blue.500' size='xl' />
|
||||
<Text mt='2' color='gray.500' userSelect='none'>
|
||||
|
||||
28
src/components/Logo/Logo.tsx
Normal file
28
src/components/Logo/Logo.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import { chakra, HTMLChakraProps, useColorModeValue, useToken } from '@chakra-ui/react';
|
||||
|
||||
const Logo: React.FC<HTMLChakraProps<'svg'>> = props => {
|
||||
const [white, black] = useToken('colors', ['white', 'gray.800']);
|
||||
return (
|
||||
<chakra.svg
|
||||
color={useColorModeValue('blue.500', 'blue.300')}
|
||||
aria-hidden
|
||||
viewBox='0 0 123 32'
|
||||
fill='none'
|
||||
h='6'
|
||||
flexShrink={0}
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d='M6.66665 17.5733V22.9067L16 28L25.3333 22.9067V17.5733L16 22.6667L6.66665 17.5733ZM16 4L1.33331 12L16 20L28 13.4533V22.6667H30.6666V12L16 4Z'
|
||||
fill='#3182CE'
|
||||
/>
|
||||
<path
|
||||
d='M40.8807 22H48.9943V19.7159H43.6932V17.3239H48.5795V15.0341H43.6932V12.6477H48.9943V10.3636H40.8807V22ZM53.9432 22.125C55.2614 22.125 56.0455 21.392 56.3977 20.5852H56.483V22H59.233V10.3636H56.4545V14.767H56.3977C56.0682 13.9659 55.3068 13.1591 53.9318 13.1591C52.108 13.1591 50.4545 14.5625 50.4545 17.642C50.4545 20.6136 52.017 22.125 53.9432 22.125ZM54.9091 19.9602C53.8807 19.9602 53.3068 19.0455 53.3068 17.6364C53.3068 16.233 53.875 15.3295 54.9091 15.3295C55.9261 15.3295 56.517 16.2102 56.517 17.6364C56.517 19.0511 55.9205 19.9602 54.9091 19.9602ZM66.6207 18.233C66.6207 19.25 65.973 19.8182 65.1491 19.8182C64.2969 19.8182 63.7741 19.2386 63.7685 18.2898V13.2727H60.9901V18.8352C60.9957 20.8068 62.1946 22.1136 63.9901 22.1136C65.2969 22.1136 66.2685 21.4432 66.6548 20.375H66.7457V22H69.3935V13.2727H66.6207V18.233ZM75.2358 22.1648C77.6903 22.1648 79.2131 20.7386 79.2869 18.5795H76.6903C76.5881 19.5057 76.0312 20.0114 75.2699 20.0114C74.2926 20.0114 73.6562 19.1875 73.6562 17.6364C73.6562 16.0966 74.2983 15.2727 75.2699 15.2727C76.0653 15.2727 76.5824 15.8125 76.6903 16.7045H79.2869C79.2244 14.5568 77.6562 13.1591 75.2301 13.1591C72.4858 13.1591 70.8267 14.9886 70.8267 17.6648C70.8267 20.3295 72.4744 22.1648 75.2358 22.1648ZM85.6349 13.2727H84.0611V11.1818H81.2827V13.2727H80.1293V15.3182H81.2827V19.5625C81.2656 21.3295 82.4134 22.2159 84.3736 22.125C85.044 22.0909 85.527 21.9545 85.794 21.875L85.3736 19.8693C85.2543 19.8977 84.9759 19.9545 84.7656 19.9545C84.3168 19.9545 84.0611 19.7727 84.0611 19.2557V15.3182H85.6349V13.2727Z'
|
||||
fill={useColorModeValue(black, white)}
|
||||
/>
|
||||
</chakra.svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default Logo;
|
||||
3
src/components/Logo/index.ts
Normal file
3
src/components/Logo/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import Logo from './Logo';
|
||||
|
||||
export default Logo;
|
||||
90
src/components/Modal/BaseModal.tsx
Normal file
90
src/components/Modal/BaseModal.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@chakra-ui/button';
|
||||
import { Text, Flex, Box, Divider } from '@chakra-ui/react';
|
||||
import {
|
||||
Icon,
|
||||
ModalProps,
|
||||
Modal,
|
||||
ModalOverlay,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalFooter,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
} from '@chakra-ui/react';
|
||||
import { IconType } from 'react-icons';
|
||||
|
||||
export interface IBaseModalProps extends ModalProps {
|
||||
isOpen: boolean;
|
||||
isDisabled?: boolean | undefined;
|
||||
onClose: () => void;
|
||||
onProceed?: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
heading: string;
|
||||
icon?: IconType;
|
||||
description?: string;
|
||||
confirmText?: string;
|
||||
isLoading?: boolean | undefined;
|
||||
loadingText?: string | undefined;
|
||||
}
|
||||
|
||||
const BaseModal: React.FC<IBaseModalProps> = props => {
|
||||
const {
|
||||
heading,
|
||||
description,
|
||||
isOpen,
|
||||
onClose,
|
||||
onProceed,
|
||||
confirmText,
|
||||
icon,
|
||||
isLoading,
|
||||
isDisabled,
|
||||
loadingText,
|
||||
...modalProps
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} {...modalProps}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>
|
||||
<Flex alignItems='center'>
|
||||
{icon && <Icon as={icon} mr='2' />}
|
||||
<Text fontWeight='bold'>{heading}</Text>
|
||||
</Flex>
|
||||
{description && (
|
||||
<Box>
|
||||
<Text fontSize='md' fontWeight='normal'>
|
||||
{description}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</ModalHeader>
|
||||
|
||||
<Divider />
|
||||
<ModalCloseButton />
|
||||
|
||||
<ModalBody>
|
||||
<Box>{props.children}</Box>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button onClick={onClose} borderRadius='md' mr='2'>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onProceed}
|
||||
isLoading={isLoading}
|
||||
isDisabled={isDisabled}
|
||||
loadingText={loadingText || 'Loading...'}
|
||||
borderRadius='md'
|
||||
variant='outline'
|
||||
colorScheme='blue'
|
||||
>
|
||||
{confirmText || 'Proceed'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default BaseModal;
|
||||
3
src/components/Modal/index.ts
Normal file
3
src/components/Modal/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import BaseModal from './BaseModal';
|
||||
|
||||
export default BaseModal;
|
||||
80
src/components/Modals/AddCategoryModal/AddCategoryModal.tsx
Normal file
80
src/components/Modals/AddCategoryModal/AddCategoryModal.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import React from 'react';
|
||||
import BaseModal from '@educt/components/Modal';
|
||||
import { FormControl, FormHelperText, FormLabel, Input, Stack, Textarea } from '@chakra-ui/react';
|
||||
import { ICategory } from '@educt/interfaces';
|
||||
import { MdCategory } from 'react-icons/md';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
|
||||
/**
|
||||
* Schema
|
||||
*/
|
||||
import AddCategorySchema from './AddCategoryModal.validator';
|
||||
|
||||
/**
|
||||
* Hooks
|
||||
*/
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useCreateCategory } from '@educt/hooks/queries';
|
||||
|
||||
type AddCategoryModalPropsType = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onAdded?: (category: ICategory) => void;
|
||||
};
|
||||
|
||||
type AddCategoryInputType = {
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
const AddCategoryModal: React.FC<AddCategoryModalPropsType> = ({ isOpen, onClose, onAdded }) => {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<AddCategoryInputType>({
|
||||
resolver: yupResolver(AddCategorySchema),
|
||||
});
|
||||
const { createCategory, isLoading } = useCreateCategory();
|
||||
|
||||
const onSubmit = async (data: AddCategoryInputType) => {
|
||||
try {
|
||||
const category = await createCategory(data);
|
||||
onClose();
|
||||
onAdded && onAdded(category);
|
||||
reset({});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseModal
|
||||
icon={MdCategory}
|
||||
heading='Create category'
|
||||
description='Create new category'
|
||||
confirmText='Create'
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
onProceed={handleSubmit(onSubmit)}
|
||||
isLoading={isLoading}
|
||||
loadingText='Saving...'
|
||||
>
|
||||
<Stack spacing={'4'}>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<Input placeholder='e.g Management' {...register('title')} isInvalid={!!errors.title} />
|
||||
{errors.title && <FormHelperText color='red.500'>{errors?.title.message}</FormHelperText>}
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<Textarea resize={'none'} placeholder='Write a description...' {...register('description')} />
|
||||
</FormControl>
|
||||
</Stack>
|
||||
</BaseModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddCategoryModal;
|
||||
@ -0,0 +1,8 @@
|
||||
import yup from '@educt/schema';
|
||||
|
||||
const AddCategorySchema = yup.object().shape({
|
||||
title: yup.string().min(2, 'Title must be greater than 2 characters').required('Title field name is required'),
|
||||
description: yup.string().optional(),
|
||||
});
|
||||
|
||||
export default AddCategorySchema;
|
||||
3
src/components/Modals/AddCategoryModal/index.ts
Normal file
3
src/components/Modals/AddCategoryModal/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import AddCategoryModal from './AddCategoryModal';
|
||||
|
||||
export default AddCategoryModal;
|
||||
73
src/components/Modals/AddStudentsModal/AddSudentsModal.tsx
Normal file
73
src/components/Modals/AddStudentsModal/AddSudentsModal.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
import React from 'react';
|
||||
import { MdGroup } from 'react-icons/md';
|
||||
import BaseModal from '@educt/components/Modal';
|
||||
import SelectStudentsInput from '@educt/components/SelectStudentsInput';
|
||||
import { UserRoleEnum } from '@educt/enums';
|
||||
|
||||
/**
|
||||
* Types
|
||||
*/
|
||||
import { ICourse, IUser } from '@educt/interfaces';
|
||||
|
||||
/**
|
||||
* Hooks
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useAttachStudents } from '@educt/hooks/queries';
|
||||
|
||||
type AddStudentsModalPropsType = {
|
||||
course: Omit<ICourse, 'students_count' | 'likes_count' | 'lessons_count'>;
|
||||
currentStudents: ICourse['students'];
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onAdded: (users: Array<IUser>) => void;
|
||||
};
|
||||
|
||||
const AddStudentsModal: React.FC<AddStudentsModalPropsType> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onAdded,
|
||||
currentStudents,
|
||||
course,
|
||||
}) => {
|
||||
const { attachStudents, isLoading } = useAttachStudents();
|
||||
const [selected, setSelected] = useState<Array<IUser>>([]);
|
||||
|
||||
const handleSelectStudents = (students: Array<IUser>) => setSelected(students);
|
||||
|
||||
const handleAttachStudents = async () => {
|
||||
if (selected.length !== 0) {
|
||||
try {
|
||||
const ids = selected.map(s => s.id);
|
||||
await attachStudents(course.id, ids);
|
||||
onAdded(selected);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseModal
|
||||
icon={MdGroup}
|
||||
heading='Add new students'
|
||||
description='Attach new students to current course.'
|
||||
confirmText='Add'
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
onProceed={handleAttachStudents}
|
||||
isLoading={isLoading}
|
||||
loadingText='Saving...'
|
||||
>
|
||||
<SelectStudentsInput
|
||||
currentStudents={currentStudents}
|
||||
onSelect={selected => handleSelectStudents(selected)}
|
||||
placeholder='Search for students...'
|
||||
searchParams={{ role: UserRoleEnum.STUDENT }}
|
||||
/>
|
||||
</BaseModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddStudentsModal;
|
||||
3
src/components/Modals/AddStudentsModal/index.ts
Normal file
3
src/components/Modals/AddStudentsModal/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import AddStudentsModal from './AddSudentsModal';
|
||||
|
||||
export default AddStudentsModal;
|
||||
@ -0,0 +1,95 @@
|
||||
import React from 'react';
|
||||
import * as helpers from '@educt/helpers';
|
||||
import BaseModal from '@educt/components/Modal';
|
||||
import { ICategory } from '@educt/interfaces';
|
||||
import { MdModeEdit } from 'react-icons/md';
|
||||
import { FormControl, FormLabel, FormHelperText, Stack, Input, Textarea } from '@chakra-ui/react';
|
||||
import { UpdateCategoryParamsType } from '@educt/types';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
|
||||
/**
|
||||
* Schema
|
||||
*/
|
||||
import EditCategorySchema from './EditCategoryModal.validator';
|
||||
import { useUpdateCategory } from '@educt/hooks/queries';
|
||||
|
||||
/**
|
||||
* Hooks
|
||||
*/
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
type EditCategoryModalPropsType = {
|
||||
category: ICategory;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onEdited?: (updatedCategory: ICategory) => void | undefined;
|
||||
};
|
||||
|
||||
type UpdateCategoryInputType = {
|
||||
title?: string | undefined;
|
||||
description?: string | null | undefined;
|
||||
};
|
||||
|
||||
const EditCategoryModal: React.FC<EditCategoryModalPropsType> = props => {
|
||||
const { category, onClose, onEdited, isOpen } = props;
|
||||
const defaultValues = {
|
||||
title: category.title,
|
||||
description: category.description,
|
||||
};
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isDirty, dirtyFields },
|
||||
} = useForm<UpdateCategoryInputType>({
|
||||
resolver: yupResolver(EditCategorySchema),
|
||||
defaultValues: useMemo(() => {
|
||||
return defaultValues;
|
||||
}, [props]),
|
||||
});
|
||||
const { updateCategory, isLoading } = useUpdateCategory();
|
||||
|
||||
const onSubmit = async (data: UpdateCategoryParamsType) => {
|
||||
try {
|
||||
const updated = await updateCategory(category.id, helpers.getDirtyFields(dirtyFields, data));
|
||||
onClose();
|
||||
onEdited && onEdited(updated);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
reset(defaultValues);
|
||||
}, [props.category]);
|
||||
|
||||
return (
|
||||
<BaseModal
|
||||
icon={MdModeEdit}
|
||||
heading='Edit category'
|
||||
confirmText='Save'
|
||||
loadingText='Saving...'
|
||||
isLoading={isLoading}
|
||||
isOpen={isOpen}
|
||||
isDisabled={!isDirty}
|
||||
onClose={onClose}
|
||||
onProceed={handleSubmit(onSubmit)}
|
||||
>
|
||||
<Stack spacing={'4'}>
|
||||
<FormControl>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<Input placeholder='e.g Management' {...register('title')} isInvalid={!!errors.title} />
|
||||
{errors.title && <FormHelperText color='red.500'>{errors?.title.message}</FormHelperText>}
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<Textarea resize={'none'} placeholder='Write a description...' {...register('description')} />
|
||||
</FormControl>
|
||||
</Stack>
|
||||
</BaseModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditCategoryModal;
|
||||
@ -0,0 +1,11 @@
|
||||
import yup from '@educt/schema';
|
||||
|
||||
const EditCategorySchema = yup.object().shape({
|
||||
title: yup.string().min(2, 'Title must be greater than 2 characters').optional(),
|
||||
description: yup
|
||||
.string()
|
||||
.transform(val => (!val ? null : val))
|
||||
.nullable(true),
|
||||
});
|
||||
|
||||
export default EditCategorySchema;
|
||||
3
src/components/Modals/EditCategoryModal/index.ts
Normal file
3
src/components/Modals/EditCategoryModal/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import EditCategoryModal from './EditCategoryModal';
|
||||
|
||||
export default EditCategoryModal;
|
||||
158
src/components/Modals/EditUserModal/EditUserModal.tsx
Normal file
158
src/components/Modals/EditUserModal/EditUserModal.tsx
Normal file
@ -0,0 +1,158 @@
|
||||
import React from 'react';
|
||||
import * as helpers from '@educt/helpers';
|
||||
import { FormControl, FormLabel, FormHelperText, Input, Select, Stack } from '@chakra-ui/react';
|
||||
import { MdModeEdit } from 'react-icons/md';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import BaseModal from '@educt/components/Modal';
|
||||
|
||||
/**
|
||||
* Types
|
||||
*/
|
||||
import { IUser } from '@educt/interfaces';
|
||||
import { UserRoleEnum } from '@educt/enums';
|
||||
import { SubmitHandler } from 'react-hook-form';
|
||||
|
||||
/**
|
||||
* Hooks
|
||||
*/
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useRootStore } from '@educt/hooks/useRootStore';
|
||||
import { useUpdateUser } from '@educt/hooks/queries';
|
||||
|
||||
/**
|
||||
* Schema
|
||||
*/
|
||||
import EditUserSchema from './EditUserModal.validator';
|
||||
|
||||
type EditUserModalPropsType = {
|
||||
user: IUser;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onEdited?: (updatedUser: IUser) => void | undefined;
|
||||
};
|
||||
|
||||
type UpdateUserInputType = {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
login: string | null;
|
||||
email: string;
|
||||
role: UserRoleEnum;
|
||||
password: string | null;
|
||||
};
|
||||
|
||||
const EditUserModal: React.FC<EditUserModalPropsType> = props => {
|
||||
const { user, onClose, onEdited, isOpen } = props;
|
||||
const {
|
||||
userStore: { me },
|
||||
} = useRootStore();
|
||||
const { updateUser, isLoading } = useUpdateUser();
|
||||
const defaultValues = {
|
||||
login: null,
|
||||
first_name: user.first_name,
|
||||
last_name: user.last_name,
|
||||
email: user.email,
|
||||
role: user.roles[0].slug,
|
||||
password: null,
|
||||
};
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isDirty, dirtyFields },
|
||||
} = useForm<UpdateUserInputType>({
|
||||
resolver: yupResolver(EditUserSchema),
|
||||
defaultValues: useMemo(() => {
|
||||
return defaultValues;
|
||||
}, [props]),
|
||||
});
|
||||
|
||||
if (me === null) return null;
|
||||
|
||||
/**
|
||||
* Submit handler
|
||||
*/
|
||||
const onSubmit: SubmitHandler<UpdateUserInputType> = async data => {
|
||||
try {
|
||||
const params = helpers.getDirtyFields<UpdateUserInputType>(dirtyFields, data);
|
||||
const updated = await updateUser(user.id, params);
|
||||
onClose();
|
||||
onEdited && onEdited(updated);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
reset(defaultValues);
|
||||
}, [props.user]);
|
||||
|
||||
return (
|
||||
<BaseModal
|
||||
icon={MdModeEdit}
|
||||
heading='Edit user'
|
||||
confirmText='Save'
|
||||
loadingText='Saving...'
|
||||
isLoading={isLoading}
|
||||
isDisabled={!isDirty}
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
onProceed={handleSubmit(onSubmit)}
|
||||
>
|
||||
<Stack spacing='4'>
|
||||
<FormControl isDisabled>
|
||||
<FormLabel>Identificator</FormLabel>
|
||||
<Input value={user.id} />
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>New Login</FormLabel>
|
||||
<Input placeholder='e.g student001' {...register('login')} isInvalid={!!errors.login} />
|
||||
{errors.login && <FormHelperText color='red.500'>{errors.login.message}</FormHelperText>}
|
||||
</FormControl>
|
||||
|
||||
<FormControl isRequired>
|
||||
<FormLabel>First name</FormLabel>
|
||||
<Input placeholder='First name' {...register('first_name')} isInvalid={!!errors.first_name} />
|
||||
{errors.first_name && <FormHelperText color='red.500'>{errors.first_name.message}</FormHelperText>}
|
||||
</FormControl>
|
||||
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Last name</FormLabel>
|
||||
<Input placeholder='Last name' {...register('last_name')} isInvalid={!!errors.last_name} />
|
||||
{errors.last_name && <FormHelperText color='red.500'>{errors.last_name.message}</FormHelperText>}
|
||||
</FormControl>
|
||||
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<Input placeholder='example@email.com' {...register('email')} isInvalid={!!errors.email} />
|
||||
{errors.email && <FormHelperText color='red.500'>{errors.email.message}</FormHelperText>}
|
||||
</FormControl>
|
||||
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Role</FormLabel>
|
||||
<Select w='full' mr='2' {...register('role')} isInvalid={!!errors.role}>
|
||||
<option value={UserRoleEnum.STUDENT}>Student</option>
|
||||
{me.isAdmin && (
|
||||
<>
|
||||
<option value={UserRoleEnum.TEACHER}>Teacher</option>
|
||||
<option value={UserRoleEnum.ADMIN}>Administrator</option>
|
||||
</>
|
||||
)}
|
||||
</Select>
|
||||
{errors.role ? <FormHelperText color='red.500'>{errors.role.message}</FormHelperText> : null}
|
||||
</FormControl>
|
||||
|
||||
<FormControl>
|
||||
<FormLabel>New Password</FormLabel>
|
||||
<Input placeholder='******' {...register('password')} isInvalid={!!errors.password} />
|
||||
<FormHelperText color={errors.password ? 'red' : 'gray.500'}>
|
||||
{errors.password ? errors.password.message : 'Must be at least 6 characters.'}
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
</BaseModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditUserModal;
|
||||
3
src/components/Modals/EditUserModal/index.ts
Normal file
3
src/components/Modals/EditUserModal/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import EditUserModal from './EditUserModal';
|
||||
|
||||
export default EditUserModal;
|
||||
@ -37,17 +37,10 @@ const Nav: React.FC = () => {
|
||||
zIndex='1'
|
||||
backgroundColor={colorMode === 'dark' ? 'gray.800' : 'white'}
|
||||
>
|
||||
<Flex
|
||||
as='nav'
|
||||
flexDirection='column'
|
||||
alignItems='flex-start'
|
||||
textDecoration='none'
|
||||
pt='4rem'
|
||||
pr='5'
|
||||
pl='5'
|
||||
pb='5'
|
||||
>
|
||||
<NavList links={config.links} />
|
||||
<Flex as='nav' flexDirection='column' alignItems='flex-start' textDecoration='none' p='32px 24px'>
|
||||
<Flex flexDir='column' pt='3rem' w='full'>
|
||||
<NavList links={config.links} />
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Box textAlign='center' mt='auto' mb='5'>
|
||||
<Text fontSize='xs' color='gray.500' lineHeight='1.6'>
|
||||
@ -55,7 +48,7 @@ const Nav: React.FC = () => {
|
||||
</Text>
|
||||
<Box textAlign='center' mt='2'>
|
||||
<IconButton variant='link' aria-label='GitHub repository'>
|
||||
<Link isExternal href='https://github.com/sergeyyarkov/educt-client'>
|
||||
<Link isExternal href={config.metaData.appGithubLink}>
|
||||
<Box as={FaGithub} />
|
||||
</Link>
|
||||
</IconButton>
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import React from 'react';
|
||||
import { observer } from 'mobx-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { IconButton, Skeleton } from '@chakra-ui/react';
|
||||
import { Link as ReactRouterLink } from 'react-router-dom';
|
||||
import { IconButton, Skeleton, Divider } from '@chakra-ui/react';
|
||||
import { Avatar } from '@chakra-ui/avatar';
|
||||
import { Flex, Box, Text } from '@chakra-ui/layout';
|
||||
import { Flex, Box, Text, Link } from '@chakra-ui/layout';
|
||||
import { Drawer, DrawerOverlay, DrawerContent, DrawerBody } from '@chakra-ui/modal';
|
||||
import { MdExitToApp } from 'react-icons/md';
|
||||
import { HamburgerIcon } from '@chakra-ui/icons';
|
||||
@ -13,11 +13,12 @@ import config from '@educt/config';
|
||||
* Components
|
||||
*/
|
||||
import NavList from '../NavList';
|
||||
import Logo from '@educt/components/Logo';
|
||||
|
||||
/**
|
||||
* Hooks
|
||||
*/
|
||||
import useLogoutQuery from '@educt/hooks/useLogoutQuery';
|
||||
import { useLogout } from '@educt/hooks/queries';
|
||||
import { useRootStore } from '@educt/hooks/useRootStore';
|
||||
import { useColorMode, useDisclosure } from '@chakra-ui/react';
|
||||
|
||||
@ -27,7 +28,7 @@ const NavMobile: React.FC = () => {
|
||||
} = useRootStore();
|
||||
const { colorMode } = useColorMode();
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const { logout } = useLogoutQuery();
|
||||
const { logout } = useLogout();
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -35,13 +36,19 @@ const NavMobile: React.FC = () => {
|
||||
<Drawer placement='left' onClose={onClose} isOpen={isOpen}>
|
||||
<DrawerOverlay />
|
||||
<DrawerContent backgroundColor={colorMode === 'dark' ? 'gray.800' : 'white'}>
|
||||
<DrawerBody>
|
||||
<DrawerBody p='0'>
|
||||
<Flex flexDirection='column' height='100%'>
|
||||
<Flex as='nav' flexDirection='column' alignItems='flex-start' textDecoration='none' mb='20px'>
|
||||
<Box px='6' py='2'>
|
||||
<Logo h='10' ml='2' />
|
||||
</Box>
|
||||
<Divider />
|
||||
<Flex px='6' flexDir='column' mt='3' w='full'>
|
||||
<NavList links={config.links} onCloseDrawer={onClose} />
|
||||
</Flex>
|
||||
{me !== null ? (
|
||||
<Box
|
||||
mx='6'
|
||||
my='3'
|
||||
minHeight='60px'
|
||||
padding='10px'
|
||||
borderRadius='md'
|
||||
@ -51,20 +58,20 @@ const NavMobile: React.FC = () => {
|
||||
<Flex alignItems='center'>
|
||||
<Avatar size='sm' name={me.fullname} marginRight={3} />
|
||||
<Flex flexDirection='column'>
|
||||
<Text as='span' mr={2} lineHeight='1.2'>
|
||||
<Text as='span' fontWeight={'semibold'} fontSize={'sm'} mr={2} lineHeight='1.2'>
|
||||
{me.fullname}
|
||||
</Text>
|
||||
<Link to={`/profile`} style={{ fontSize: '13px', textDecoration: 'underline' }} onClick={onClose}>
|
||||
<Link
|
||||
as={ReactRouterLink}
|
||||
fontSize={'sm'}
|
||||
to={`/profile`}
|
||||
style={{ fontSize: '13px', textDecoration: 'underline' }}
|
||||
onClick={onClose}
|
||||
>
|
||||
View profile
|
||||
</Link>
|
||||
</Flex>
|
||||
<IconButton
|
||||
onClick={logout}
|
||||
aria-label='Logout'
|
||||
backgroundColor={colorMode === 'dark' ? 'gray.600' : 'gray.200'}
|
||||
marginLeft='auto'
|
||||
icon={<MdExitToApp />}
|
||||
/>
|
||||
<IconButton onClick={logout} aria-label='Logout' marginLeft='auto' icon={<MdExitToApp />} />
|
||||
</Flex>
|
||||
</Box>
|
||||
) : (
|
||||
|
||||
@ -1,62 +1,67 @@
|
||||
import React from 'react';
|
||||
import { observer } from 'mobx-react';
|
||||
import { userContainRoles } from '@educt/helpers';
|
||||
import { Box, useColorMode } from '@chakra-ui/react';
|
||||
import * as helpers from '@educt/helpers';
|
||||
import { Flex, Box, Icon, Text } from '@chakra-ui/react';
|
||||
|
||||
/**
|
||||
* Types
|
||||
*/
|
||||
import type { LinkType } from '@educt/types';
|
||||
|
||||
/**
|
||||
* Hooks
|
||||
*/
|
||||
import { useRootStore } from '@educt/hooks/useRootStore';
|
||||
import { useColorMode } from '@chakra-ui/react';
|
||||
|
||||
type NavLinkPropsType = {
|
||||
link: LinkType;
|
||||
onClickLink: (link: LinkType) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the link component for navigation.
|
||||
*/
|
||||
const NavLink: React.FC<NavLinkPropsType> = ({ link, onClickLink }) => {
|
||||
const { uiStore, userStore } = useRootStore();
|
||||
const { colorMode } = useColorMode();
|
||||
const isActive = uiStore.location === link.location;
|
||||
const onClick = () => onClickLink(link);
|
||||
|
||||
if (!link.public && link.roles !== undefined) {
|
||||
const { me } = userStore;
|
||||
if (me === null) return null;
|
||||
if (!userContainRoles(me.roles, link.roles)) return null;
|
||||
if (!helpers.userContainRoles(me.roles, link.roles)) return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
as='a'
|
||||
onClick={onClick}
|
||||
display='flex'
|
||||
margin='3px 0'
|
||||
width='100%'
|
||||
border='none'
|
||||
display='block'
|
||||
aria-label={link.title}
|
||||
role='link'
|
||||
fontWeight='medium'
|
||||
borderRadius='md'
|
||||
mt='0.5rem'
|
||||
pt='2'
|
||||
pb='2'
|
||||
paddingInlineStart='3'
|
||||
paddingInlineEnd='3'
|
||||
transition='all 0.3s ease 0s'
|
||||
lineHeight='1.5rem'
|
||||
textDecoration='none'
|
||||
cursor='pointer'
|
||||
outline='transparent solid 2px'
|
||||
outlineOffset='2px'
|
||||
color={isActive ? 'white' : ''}
|
||||
backgroundColor={isActive ? `${colorMode === 'dark' ? 'gray.700' : 'blue.500'}` : ''}
|
||||
_hover={{
|
||||
bg: `${colorMode === 'dark' ? 'gray.700' : `${!isActive ? 'gray.100' : ''}`}`,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
color={uiStore.location === link.location ? 'blue.400' : ''}
|
||||
backgroundColor={uiStore.location === link.location ? `${colorMode === 'dark' ? 'gray.700' : 'gray.100'}` : ''}
|
||||
alignItems='center'
|
||||
borderRadius='3xl'
|
||||
display='flex'
|
||||
padding='10px 15px'
|
||||
fontWeight='500'
|
||||
transition='all .1s'
|
||||
_hover={{
|
||||
bg: `${colorMode === 'dark' ? 'gray.700' : 'gray.100'}`,
|
||||
color: `blue.400`,
|
||||
}}
|
||||
>
|
||||
<Box as={link.icon} boxSize='26px' mr={3} />
|
||||
{link.title}
|
||||
</Box>
|
||||
<Flex flexDir='row' alignItems='center'>
|
||||
<Icon as={link.icon} boxSize='21px' />
|
||||
<Text as='span' ml='16px'>
|
||||
{link.title}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
48
src/components/PageElements/index.tsx
Normal file
48
src/components/PageElements/index.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { BoxProps, Box, Flex, Heading, Text, Divider } from '@chakra-ui/react';
|
||||
|
||||
interface IPageComposition {
|
||||
Heading: React.FC<IPageHeadingProps & BoxProps>;
|
||||
Content: React.FC<BoxProps>;
|
||||
Footer: React.FC<BoxProps>;
|
||||
}
|
||||
|
||||
const Page: React.FC<BoxProps> & IPageComposition = props => {
|
||||
return (
|
||||
<Box maxW='85rem' ml='auto' mr='auto' mt='7rem' mb='2rem' px='2rem' {...props}>
|
||||
{props.children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
interface IPageHeadingProps {
|
||||
headingPrefix?: JSX.Element;
|
||||
heading: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
Page.Heading = props => {
|
||||
const { headingPrefix, heading, description, ...boxProps } = props;
|
||||
return (
|
||||
<Box {...boxProps}>
|
||||
<Flex alignItems={'center'}>
|
||||
{headingPrefix}
|
||||
<Heading as='h1'>{heading}</Heading>
|
||||
</Flex>
|
||||
{description && <Text mt='2'>{description}</Text>}
|
||||
{props.children}
|
||||
<Divider my='5' />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
Page.Content = props => <Box {...props}>{props.children}</Box>;
|
||||
|
||||
Page.Footer = props => (
|
||||
<Box mt='4' {...props}>
|
||||
<Divider m='20px 0' />
|
||||
{props.children}
|
||||
</Box>
|
||||
);
|
||||
|
||||
export { Page };
|
||||
154
src/components/SelectStudentsInput/SelectStudentsInput.tsx
Normal file
154
src/components/SelectStudentsInput/SelectStudentsInput.tsx
Normal file
@ -0,0 +1,154 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
Tag,
|
||||
TagLabel,
|
||||
TagCloseButton,
|
||||
Input,
|
||||
Box,
|
||||
Flex,
|
||||
IconButton,
|
||||
InputGroup,
|
||||
Text,
|
||||
Stack,
|
||||
InputRightElement,
|
||||
Avatar,
|
||||
} from '@chakra-ui/react';
|
||||
import { AddIcon, SearchIcon } from '@chakra-ui/icons';
|
||||
import { UserServiceInstance } from '@educt/services';
|
||||
import { ICourse, IUser } from '@educt/interfaces';
|
||||
import { useErrorHandler } from 'react-error-boundary';
|
||||
import { BeatLoader } from 'react-spinners';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
import { FetchUsersParamsType } from '@educt/types';
|
||||
|
||||
type SelectStudentsInputPropsType = {
|
||||
placeholder?: string | undefined;
|
||||
searchParams?: FetchUsersParamsType | undefined;
|
||||
onSelect: (selected: Array<IUser>) => void;
|
||||
currentStudents: ICourse['students'];
|
||||
};
|
||||
|
||||
const SelectStudentsInput: React.FC<SelectStudentsInputPropsType> = ({
|
||||
placeholder,
|
||||
searchParams: params,
|
||||
onSelect,
|
||||
currentStudents,
|
||||
}) => {
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [search, setSearch] = useState<string>('');
|
||||
const [selected, setSelected] = useState<Array<IUser>>([]);
|
||||
const [users, setUsers] = useState<Array<IUser>>([]);
|
||||
const filtered = users
|
||||
.filter(u => selected.every(s => s.id !== u.id))
|
||||
.filter(u => currentStudents.every(s => s.id !== u.id));
|
||||
const handleError = useErrorHandler();
|
||||
const isEmpty = filtered.length === 0;
|
||||
|
||||
const fetch = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const { data } = await UserServiceInstance.fetchAll({ search, ...params });
|
||||
setUsers(data);
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => setSearch(e.target.value);
|
||||
|
||||
const handleSelect = (user: IUser) => () => {
|
||||
setSelected(prev => {
|
||||
const updated = [...prev, user];
|
||||
onSelect(updated);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveSelected = (id: IUser['id']) => () => {
|
||||
setSelected(prev => {
|
||||
const updated = prev.filter(u => u.id !== id);
|
||||
onSelect(updated);
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetch();
|
||||
}, [search]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{selected.length !== 0 && (
|
||||
<Box mt='1'>
|
||||
{selected.map(selected => (
|
||||
<Tag key={selected.id} size='md' colorScheme='blue' borderRadius='full' mr='1' mb='1'>
|
||||
<Avatar size='xs' name={selected.fullname} ml={-1} mr={2} />
|
||||
<TagLabel>{selected.fullname}</TagLabel>
|
||||
<TagCloseButton onClick={handleRemoveSelected(selected.id)} />
|
||||
</Tag>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
<InputGroup mt='2'>
|
||||
<Input
|
||||
value={search}
|
||||
onFocus={fetch}
|
||||
onChange={handleSearch}
|
||||
type='text'
|
||||
placeholder={placeholder || 'Search for users...'}
|
||||
/>
|
||||
<InputRightElement pointerEvents='none'>
|
||||
<SearchIcon color='gray.300' />
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
|
||||
{isLoading && (
|
||||
<Text as='small' color='gray.500'>
|
||||
Loading...
|
||||
<BeatLoader size={6} color='gray' />
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!isLoading && isEmpty && (
|
||||
<Text as='small' color='red.500'>
|
||||
Cannot find any user
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!isLoading && !isEmpty && (
|
||||
<Stack borderWidth='1px' mt='2' borderRadius='md'>
|
||||
<Virtuoso
|
||||
style={{ height: filtered.length * 54, maxHeight: 216 }}
|
||||
data={filtered}
|
||||
itemContent={(i, user) => (
|
||||
<Flex key={user.id} borderBottomWidth='1px' p='7px 16px' alignItems='center'>
|
||||
<Avatar name={user.fullname} size='xs' mr={{ base: '0', sm: '3' }} mb={{ base: '1', sm: '0' }} />
|
||||
<Box textAlign={{ base: 'center', sm: 'left' }}>
|
||||
<Text fontWeight='medium' fontSize='sm'>
|
||||
{user.fullname}
|
||||
</Text>
|
||||
<Text color='gray.500' fontSize='xs' overflowWrap='anywhere'>
|
||||
{user.email}
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton
|
||||
onClick={handleSelect(user)}
|
||||
aria-label='Add student'
|
||||
icon={<AddIcon />}
|
||||
ml='auto'
|
||||
borderRadius='full'
|
||||
size='sm'
|
||||
/>
|
||||
</Flex>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectStudentsInput;
|
||||
3
src/components/SelectStudentsInput/index.ts
Normal file
3
src/components/SelectStudentsInput/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import SelectStudentsInput from './SelectStudentsInput';
|
||||
|
||||
export default SelectStudentsInput;
|
||||
@ -1,3 +0,0 @@
|
||||
import SetCourseStatusButton from './SetCourseStatusButton';
|
||||
|
||||
export default SetCourseStatusButton;
|
||||
45
src/components/Stat/index.tsx
Normal file
45
src/components/Stat/index.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import * as React from 'react';
|
||||
import {
|
||||
Stat as ChakraStat,
|
||||
StatProps,
|
||||
useColorModeValue,
|
||||
StatLabel as ChakraStatLabel,
|
||||
StatLabel as ChakraStatNumber,
|
||||
StatLabelProps,
|
||||
Icon,
|
||||
FlexProps,
|
||||
Flex,
|
||||
} from '@chakra-ui/react';
|
||||
import { IconType } from 'react-icons';
|
||||
|
||||
export const Stat: React.FC = (props: StatProps) => (
|
||||
<ChakraStat
|
||||
px={{ base: 4, sm: 6 }}
|
||||
py='5'
|
||||
bg={useColorModeValue('white', 'gray.700')}
|
||||
shadow='base'
|
||||
rounded='lg'
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export const StatLabel: React.FC = (props: StatLabelProps) => (
|
||||
<ChakraStatLabel fontWeight='medium' isTruncated color={useColorModeValue('gray.500', 'gray.400')} {...props} />
|
||||
);
|
||||
|
||||
export const StatNumber: React.FC = (props: StatLabelProps) => (
|
||||
<ChakraStatNumber mt='2' fontWeight='medium' fontSize={'5xl'} isTruncated {...props} />
|
||||
);
|
||||
|
||||
type StatIconPropsType = {
|
||||
icon: IconType;
|
||||
};
|
||||
export const StatIcon: React.FC<StatIconPropsType> = props => (
|
||||
<Icon as={props.icon} color={useColorModeValue('gray.700', 'gray.300')} width={10} height={10} />
|
||||
);
|
||||
|
||||
export const StatContent: React.FC<FlexProps> = props => (
|
||||
<Flex justifyContent={'space-between'} alignItems={'flex-end'}>
|
||||
{props.children}
|
||||
</Flex>
|
||||
);
|
||||
@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import { Icon, Flex, Text } from '@chakra-ui/react';
|
||||
import { Thead, Tr, Th } from '@chakra-ui/react';
|
||||
import { MdCategory, MdColorLens, MdSettings } from 'react-icons/md';
|
||||
|
||||
/**
|
||||
* Hooks
|
||||
*/
|
||||
import { useColorModeValue } from '@chakra-ui/react';
|
||||
|
||||
const CategoryTableHead: React.FC = () => {
|
||||
return (
|
||||
<Thead bg={useColorModeValue('gray.50', 'gray.700')}>
|
||||
<Tr>
|
||||
<Th width='4px'>
|
||||
<Icon as={MdColorLens} w={4} h={4} />
|
||||
</Th>
|
||||
<Th>
|
||||
<Flex alignItems='center'>
|
||||
<Icon as={MdCategory} w={4} h={4} />
|
||||
<Text as='span' ml='1'>
|
||||
Name
|
||||
</Text>
|
||||
</Flex>
|
||||
</Th>
|
||||
<Th>Created</Th>
|
||||
<Th>Last update</Th>
|
||||
<Th width='20px'>
|
||||
<Flex>
|
||||
<Icon as={MdSettings} w={4} h={4} />
|
||||
<Text as='span' ml='1'>
|
||||
Actions
|
||||
</Text>
|
||||
</Flex>
|
||||
</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
);
|
||||
};
|
||||
|
||||
export default CategoryTableHead;
|
||||
114
src/components/Tables/CategoryTableList/CategoryTableList.tsx
Normal file
114
src/components/Tables/CategoryTableList/CategoryTableList.tsx
Normal file
@ -0,0 +1,114 @@
|
||||
import React from 'react';
|
||||
import update from 'immutability-helper';
|
||||
import { Flex, Box, Text, Table, Tbody } from '@chakra-ui/react';
|
||||
import { CreateButton } from '@educt/components/Buttons';
|
||||
import { ICategory } from '@educt/interfaces';
|
||||
import CategoryTableHead from './CategoryTableHead';
|
||||
import { CategoryTableRowPropsType } from './CategoryTableRow';
|
||||
import AddCategoryModal from '@educt/components/Modals/AddCategoryModal';
|
||||
import DeleteCategoryDialog from '@educt/components/Dialogs/DeleteCategoryDialog';
|
||||
import EditCategoryModal from '@educt/components/Modals/EditCategoryModal';
|
||||
|
||||
/**
|
||||
* Hooks
|
||||
*/
|
||||
import { useState } from 'react';
|
||||
import { useDisclosure } from '@chakra-ui/react';
|
||||
|
||||
type CategoryTableListPropsType = {
|
||||
render: React.FC<CategoryTableRowPropsType>;
|
||||
categories: ICategory[];
|
||||
};
|
||||
|
||||
const CategoryTableList: React.FC<CategoryTableListPropsType> = props => {
|
||||
const { render: Row, categories } = props;
|
||||
const [rows, setRows] = useState<ICategory[]>(categories);
|
||||
const [deleting, setDeleting] = useState<ICategory | null>(null);
|
||||
const [editing, setEditing] = useState<ICategory | null>(null);
|
||||
const { onOpen: onOpenCreateModal, onClose: onCloseCreateyModal, isOpen: isOpenCreateModal } = useDisclosure();
|
||||
const { onOpen: onOpenEditModal, onClose: onCloseEditModal, isOpen: isOpenEditModal } = useDisclosure();
|
||||
const { onOpen: onOpenDeleteDialog, onClose: onCloseDeleteDialog, isOpen: isOpenDeleteDialog } = useDisclosure();
|
||||
|
||||
const isEmptyRows = rows.length === 0;
|
||||
|
||||
const handleAddRow = (category: ICategory) => setRows(rows => [...rows, category]);
|
||||
|
||||
const handleRemoveRow = (removed: ICategory) => setRows(rows => rows.filter(category => category.id !== removed.id));
|
||||
|
||||
const handleEditRow = (edited: ICategory) => {
|
||||
const index = rows.findIndex(row => row.id === edited.id);
|
||||
const updated = update(rows, {
|
||||
[index]: {
|
||||
title: { $set: edited.title },
|
||||
description: { $set: edited.description },
|
||||
},
|
||||
});
|
||||
setRows(updated);
|
||||
};
|
||||
|
||||
const handleDelete = (category: ICategory) => () => {
|
||||
setDeleting(category);
|
||||
onOpenDeleteDialog();
|
||||
};
|
||||
|
||||
const handleEdit = (category: ICategory) => () => {
|
||||
setEditing(category);
|
||||
onOpenEditModal();
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<AddCategoryModal
|
||||
isOpen={isOpenCreateModal}
|
||||
onClose={onCloseCreateyModal}
|
||||
onAdded={category => handleAddRow(category)}
|
||||
/>
|
||||
{deleting && (
|
||||
<DeleteCategoryDialog
|
||||
category={deleting}
|
||||
isOpen={isOpenDeleteDialog}
|
||||
onClose={onCloseDeleteDialog}
|
||||
onDeleted={category => handleRemoveRow(category)}
|
||||
/>
|
||||
)}
|
||||
{editing && (
|
||||
<EditCategoryModal
|
||||
category={editing}
|
||||
isOpen={isOpenEditModal}
|
||||
onClose={onCloseEditModal}
|
||||
onEdited={category => handleEditRow(category)}
|
||||
/>
|
||||
)}
|
||||
<Flex justifyContent={'flex-end'}>
|
||||
<CreateButton onClick={onOpenCreateModal} />
|
||||
</Flex>
|
||||
<Table borderRadius='lg' mt='2'>
|
||||
<CategoryTableHead />
|
||||
<Tbody>
|
||||
{rows.map(category => (
|
||||
<Row
|
||||
key={category.id}
|
||||
category={category}
|
||||
actions={{
|
||||
onDelete: handleDelete,
|
||||
onEdit: handleEdit,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
{isEmptyRows && (
|
||||
<Box textAlign='center' m='6'>
|
||||
<Text color='gray.500' userSelect='none'>
|
||||
Cannot find any category.
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Text mt='4' fontSize='sm' color='gray.500'>
|
||||
{rows.length} categories
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CategoryTableList;
|
||||
26
src/components/Tables/CategoryTableList/CategoryTableRow.tsx
Normal file
26
src/components/Tables/CategoryTableList/CategoryTableRow.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import { ICategory } from '@educt/interfaces';
|
||||
import { Tr } from '@chakra-ui/react';
|
||||
import { ActionsCell, ColorCell, CreatedCell, NameCell, UpdatedCell } from './Cells';
|
||||
|
||||
export type CategoryTableRowPropsType = {
|
||||
category: ICategory;
|
||||
actions: {
|
||||
onEdit: (category: ICategory) => () => void;
|
||||
onDelete: (category: ICategory) => () => void;
|
||||
};
|
||||
};
|
||||
|
||||
const CategoryTableRow: React.FC<CategoryTableRowPropsType> = ({ category, actions }) => {
|
||||
return (
|
||||
<Tr>
|
||||
<ColorCell color={category.color} />
|
||||
<NameCell name={category.title} />
|
||||
<CreatedCell created={category.created_at} />
|
||||
<UpdatedCell updated={category.updated_at} />
|
||||
<ActionsCell onEdit={actions.onEdit(category)} onDelete={actions.onDelete(category)} />
|
||||
</Tr>
|
||||
);
|
||||
};
|
||||
|
||||
export default CategoryTableRow;
|
||||
54
src/components/Tables/CategoryTableList/Cells/index.tsx
Normal file
54
src/components/Tables/CategoryTableList/Cells/index.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import { Box, Td, IconButton } from '@chakra-ui/react';
|
||||
import { Menu, MenuList, MenuButton, MenuItem } from '@chakra-ui/react';
|
||||
import { MdEdit, MdMoreHoriz } from 'react-icons/md';
|
||||
import { DeleteIcon } from '@chakra-ui/icons';
|
||||
import { ICategory } from '@educt/interfaces';
|
||||
|
||||
type ColorCellPropsType = {
|
||||
color: ICategory['color'];
|
||||
};
|
||||
export const ColorCell: React.FC<ColorCellPropsType> = ({ color }) => (
|
||||
<Td>
|
||||
<Box backgroundColor={color?.hex || 'blue.500'} borderRadius={'3px'} h='20px' w='20px' />
|
||||
</Td>
|
||||
);
|
||||
|
||||
type NameCellPropsType = {
|
||||
name: string;
|
||||
};
|
||||
export const NameCell: React.FC<NameCellPropsType> = ({ name }) => <Td>{name}</Td>;
|
||||
|
||||
type CreatedCellPropsType = {
|
||||
created: string;
|
||||
};
|
||||
export const CreatedCell: React.FC<CreatedCellPropsType> = ({ created }) => (
|
||||
<Td>{new Date(created).toLocaleDateString()}</Td>
|
||||
);
|
||||
|
||||
type UpdatedCellPropsType = {
|
||||
updated: string;
|
||||
};
|
||||
export const UpdatedCell: React.FC<UpdatedCellPropsType> = ({ updated }) => (
|
||||
<Td>{new Date(updated).toLocaleDateString()}</Td>
|
||||
);
|
||||
|
||||
type ActionsCellPropsType = {
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
export const ActionsCell: React.FC<ActionsCellPropsType> = ({ onEdit, onDelete }) => (
|
||||
<Td textAlign='center'>
|
||||
<Menu isLazy>
|
||||
<MenuButton as={IconButton} aria-label='Actions' icon={<MdMoreHoriz />} variant='ghost' />
|
||||
<MenuList>
|
||||
<MenuItem onClick={onEdit} icon={<MdEdit />}>
|
||||
Edit
|
||||
</MenuItem>
|
||||
<MenuItem onClick={onDelete} icon={<DeleteIcon />} color='red.400'>
|
||||
Delete
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</Td>
|
||||
);
|
||||
4
src/components/Tables/CategoryTableList/index.tsx
Normal file
4
src/components/Tables/CategoryTableList/index.tsx
Normal file
@ -0,0 +1,4 @@
|
||||
import CategoryTableList from './CategoryTableList';
|
||||
import CategoryTableRow from './CategoryTableRow';
|
||||
|
||||
export { CategoryTableList, CategoryTableRow };
|
||||
@ -1,38 +1,30 @@
|
||||
import { Badge } from '@chakra-ui/react';
|
||||
import { BadgeProps, Badge } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
import { UserRoleEnum } from '@educt/enums';
|
||||
import { IUserRole } from '@educt/interfaces';
|
||||
import { userHasRoles } from '@educt/helpers';
|
||||
|
||||
/**
|
||||
* Returns the badge for user based on roles
|
||||
*/
|
||||
const UserBadge: React.FC<{ roles: IUserRole[] }> = ({ roles }) => {
|
||||
export interface IUserBadgeProps extends BadgeProps {
|
||||
roles: IUserRole[];
|
||||
}
|
||||
|
||||
const UserBadge: React.FC<IUserBadgeProps> = props => {
|
||||
const { roles } = props;
|
||||
const getCurrentRole = () => {
|
||||
if (userHasRoles(roles, [UserRoleEnum.ADMIN])) return { name: 'Administrator', color: 'purple' };
|
||||
if (userHasRoles(roles, [UserRoleEnum.TEACHER])) return { name: 'Teacher', color: 'orange' };
|
||||
if (userHasRoles(roles, [UserRoleEnum.STUDENT])) return { name: 'Student', color: 'blue' };
|
||||
|
||||
/**
|
||||
* The user has no roles
|
||||
*/
|
||||
return { name: 'Guest', color: 'gray' };
|
||||
};
|
||||
|
||||
const role = getCurrentRole();
|
||||
|
||||
if (role !== undefined) {
|
||||
return (
|
||||
<Badge colorScheme={role.color} variant='subtle'>
|
||||
{role.name}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cannot find any role for user
|
||||
*/
|
||||
return null;
|
||||
return (
|
||||
<Badge colorScheme={role.color} variant='subtle' {...props}>
|
||||
{role.name}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserBadge;
|
||||
|
||||
20
src/components/UserProfile/Avatar/Avatar.tsx
Normal file
20
src/components/UserProfile/Avatar/Avatar.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import { Flex, Avatar as ChakraAvatar, AvatarBadge as ChakraAvatarBadge, useColorModeValue } from '@chakra-ui/react';
|
||||
import type { IAvatarProps } from '.';
|
||||
|
||||
const Avatar: React.FC<IAvatarProps> = props => {
|
||||
const { name, isOnline } = props;
|
||||
const onlineColor = isOnline ? 'green.500' : 'red.500';
|
||||
const borderColor = useColorModeValue('#E2E8F0', '#2D3748');
|
||||
|
||||
return (
|
||||
<Flex flexDir={'column'} alignItems={'center'}>
|
||||
<ChakraAvatar border={`4px solid ${borderColor}`} borderColor={borderColor} name={name} size='2xl'>
|
||||
{isOnline !== undefined && <ChakraAvatarBadge boxSize='0.75em' bg={onlineColor} />}
|
||||
</ChakraAvatar>
|
||||
{props.children}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export { Avatar };
|
||||
19
src/components/UserProfile/Avatar/ChatButton.tsx
Normal file
19
src/components/UserProfile/Avatar/ChatButton.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import { Button, ButtonProps } from '@chakra-ui/react';
|
||||
import { MdOutlineMessage } from 'react-icons/md';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import type { IChatButtonProps } from '.';
|
||||
|
||||
const ChatButton: React.FC<IChatButtonProps & ButtonProps> = props => {
|
||||
const { userid } = props;
|
||||
const history = useHistory();
|
||||
const handleChat = () => history.push(`/messages?chat_id=${userid}`);
|
||||
|
||||
return (
|
||||
<Button onClick={handleChat} mt='5' variant={'outline'} size={'sm'} leftIcon={<MdOutlineMessage />} {...props}>
|
||||
Chat
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export { ChatButton };
|
||||
13
src/components/UserProfile/Avatar/index.ts
Normal file
13
src/components/UserProfile/Avatar/index.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { Avatar } from './Avatar';
|
||||
import { ChatButton } from './ChatButton';
|
||||
|
||||
export interface IAvatarProps {
|
||||
name: string;
|
||||
isOnline?: boolean | undefined;
|
||||
}
|
||||
|
||||
export interface IChatButtonProps {
|
||||
userid: string;
|
||||
}
|
||||
|
||||
export { Avatar, ChatButton };
|
||||
12
src/components/UserProfile/Base/Base.tsx
Normal file
12
src/components/UserProfile/Base/Base.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { Flex, FlexProps } from '@chakra-ui/react';
|
||||
import React from 'react';
|
||||
|
||||
const Base: React.FC<FlexProps> = props => {
|
||||
return (
|
||||
<Flex flexDir={{ base: 'column', md: 'row' }} {...props}>
|
||||
{props.children}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export { Base };
|
||||
1
src/components/UserProfile/Base/index.ts
Normal file
1
src/components/UserProfile/Base/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { Base } from './Base';
|
||||
21
src/components/UserProfile/Info/About.tsx
Normal file
21
src/components/UserProfile/Info/About.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from '@chakra-ui/react';
|
||||
import type { IAboutProps } from '.';
|
||||
|
||||
const About: React.FC<IAboutProps> = ({ about }) => {
|
||||
return (
|
||||
<Box>
|
||||
<Text fontSize={'sm'}>
|
||||
{about ? (
|
||||
about
|
||||
) : (
|
||||
<Text as='span' color='gray.500' userSelect={'none'}>
|
||||
No description provided
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export { About };
|
||||
37
src/components/UserProfile/Info/Contacts.tsx
Normal file
37
src/components/UserProfile/Info/Contacts.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import { observer } from 'mobx-react';
|
||||
import { Button, HStack } from '@chakra-ui/react';
|
||||
import { FaPhone, FaTelegram, FaTwitter } from 'react-icons/fa';
|
||||
import type { IContactsProps } from '.';
|
||||
|
||||
const Contacts: React.FC<IContactsProps> = observer(({ contacts }) => {
|
||||
if (contacts === null) return null;
|
||||
|
||||
const { phone_number, twitter_id, telegram_id } = contacts;
|
||||
|
||||
const phoneHandler = () => window.open(`tel:${phone_number}`, '_blank');
|
||||
const twitterHandler = () => window.open(`https://twitter.com/${twitter_id?.substring(1)}`, '_blank');
|
||||
const telegramHandler = () => window.open(`https://t.me/${telegram_id?.substring(1)}`, '_blank');
|
||||
|
||||
return (
|
||||
<HStack mt='5'>
|
||||
{phone_number && (
|
||||
<Button onClick={phoneHandler} colorScheme='whatsapp' size='xs' leftIcon={<FaPhone />}>
|
||||
Call me
|
||||
</Button>
|
||||
)}
|
||||
{twitter_id && (
|
||||
<Button onClick={twitterHandler} colorScheme='twitter' size='xs' leftIcon={<FaTwitter />}>
|
||||
Twitter
|
||||
</Button>
|
||||
)}
|
||||
{telegram_id && (
|
||||
<Button onClick={telegramHandler} colorScheme={'telegram'} size='xs' leftIcon={<FaTelegram />}>
|
||||
Telegram
|
||||
</Button>
|
||||
)}
|
||||
</HStack>
|
||||
);
|
||||
});
|
||||
|
||||
export { Contacts };
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user