mirror of
https://github.com/sergeyyarkov/educt-client.git
synced 2026-10-11 10:19:22 +03:00
Merge branch 'feature/main-page' into develop
This commit is contained in:
commit
47e16e06de
95
package-lock.json
generated
95
package-lock.json
generated
@ -1164,6 +1164,16 @@
|
||||
"picomatch": "^2.2.2"
|
||||
}
|
||||
},
|
||||
"@socket.io/base64-arraybuffer": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@socket.io/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
|
||||
"integrity": "sha512-dOlCBKnDw4iShaIsH/bxujKTM18+2TOAsYz+KSc11Am38H4q5Xw8Bbz97ZYdrVNM+um3p7w86Bvvmcn9q+5+eQ=="
|
||||
},
|
||||
"@socket.io/component-emitter": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.0.0.tgz",
|
||||
"integrity": "sha512-2pTGuibAXJswAPJjaKisthqS/NOK5ypG4LYT6tEAV0S/mxW0zOIvYvGK0V8w8+SHxAm6vRMSjqSalFXeBAqs+Q=="
|
||||
},
|
||||
"@types/history": {
|
||||
"version": "4.7.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.9.tgz",
|
||||
@ -1582,6 +1592,11 @@
|
||||
"resolve": "^1.12.0"
|
||||
}
|
||||
},
|
||||
"backo2": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz",
|
||||
"integrity": "sha1-MasayLEpNjRj41s+u2n038+6eUc="
|
||||
},
|
||||
"balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
@ -1730,7 +1745,6 @@
|
||||
"version": "4.3.2",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.2.tgz",
|
||||
"integrity": "sha512-mOp8wKcvj7XxC78zLgw/ZA+6TSgkoE2C/ienthhRD298T7UNwAg9diBpLRxC0mOezLl4B0xV7M0cCO6P/O0Xhw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"ms": "2.1.2"
|
||||
}
|
||||
@ -1790,6 +1804,30 @@
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true
|
||||
},
|
||||
"engine.io-client": {
|
||||
"version": "6.1.1",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.1.1.tgz",
|
||||
"integrity": "sha512-V05mmDo4gjimYW+FGujoGmmmxRaDsrVr7AXA3ZIfa04MWM1jOfZfUwou0oNqhNwy/votUDvGDt4JA4QF4e0b4g==",
|
||||
"requires": {
|
||||
"@socket.io/component-emitter": "~3.0.0",
|
||||
"debug": "~4.3.1",
|
||||
"engine.io-parser": "~5.0.0",
|
||||
"has-cors": "1.1.0",
|
||||
"parseqs": "0.0.6",
|
||||
"parseuri": "0.0.6",
|
||||
"ws": "~8.2.3",
|
||||
"xmlhttprequest-ssl": "~2.0.0",
|
||||
"yeast": "0.1.2"
|
||||
}
|
||||
},
|
||||
"engine.io-parser": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.0.3.tgz",
|
||||
"integrity": "sha512-BtQxwF27XUNnSafQLvDi0dQ8s3i6VgzSoQMJacpIcGNrlUdfHSKbgm3jmjCVvQluGzqwujQMPAoMai3oYSTurg==",
|
||||
"requires": {
|
||||
"@socket.io/base64-arraybuffer": "~1.0.2"
|
||||
}
|
||||
},
|
||||
"enquirer": {
|
||||
"version": "2.3.6",
|
||||
"resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz",
|
||||
@ -2519,6 +2557,11 @@
|
||||
"integrity": "sha512-LSBS2LjbNBTf6287JEbEzvJgftkF5qFkmCo9hDRpAzKhUOlJ+hx8dd4USs00SgsUNwc4617J9ki5YtEClM2ffA==",
|
||||
"dev": true
|
||||
},
|
||||
"has-cors": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-cors/-/has-cors-1.1.0.tgz",
|
||||
"integrity": "sha1-XkdHk/fqmEPRu5nCPu9J/xJv/zk="
|
||||
},
|
||||
"has-flag": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
|
||||
@ -2973,8 +3016,7 @@
|
||||
"ms": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
|
||||
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
|
||||
"dev": true
|
||||
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
|
||||
},
|
||||
"nanoclone": {
|
||||
"version": "0.2.1",
|
||||
@ -3122,6 +3164,16 @@
|
||||
"lines-and-columns": "^1.1.6"
|
||||
}
|
||||
},
|
||||
"parseqs": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/parseqs/-/parseqs-0.0.6.tgz",
|
||||
"integrity": "sha512-jeAGzMDbfSHHA091hr0r31eYfTig+29g3GKKE/PPbEQ65X0lmMwlEoqmhzu0iztID5uJpZsFlUPDP8ThPL7M8w=="
|
||||
},
|
||||
"parseuri": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/parseuri/-/parseuri-0.0.6.tgz",
|
||||
"integrity": "sha512-AUjen8sAkGgao7UyCX6Ahv0gIK2fABKmYjvP4xmy5JaKvcbTRueIqIPHLAfq30xJddqSE033IOMUSOMCcK3Sow=="
|
||||
},
|
||||
"path-is-absolute": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||
@ -3713,6 +3765,28 @@
|
||||
"integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==",
|
||||
"dev": true
|
||||
},
|
||||
"socket.io-client": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.4.1.tgz",
|
||||
"integrity": "sha512-N5C/L5fLNha5Ojd7Yeb/puKcPWWcoB/A09fEjjNsg91EDVr5twk/OEyO6VT9dlLSUNY85NpW6KBhVMvaLKQ3vQ==",
|
||||
"requires": {
|
||||
"@socket.io/component-emitter": "~3.0.0",
|
||||
"backo2": "~1.0.2",
|
||||
"debug": "~4.3.2",
|
||||
"engine.io-client": "~6.1.1",
|
||||
"parseuri": "0.0.6",
|
||||
"socket.io-parser": "~4.1.1"
|
||||
}
|
||||
},
|
||||
"socket.io-parser": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.1.1.tgz",
|
||||
"integrity": "sha512-USQVLSkDWE5nbcY760ExdKaJxCE65kcsG/8k5FDGZVVxpD1pA7hABYXYkCUvxUuYYh/+uQw0N/fvBzfT8o07KA==",
|
||||
"requires": {
|
||||
"@socket.io/component-emitter": "~3.0.0",
|
||||
"debug": "~4.3.1"
|
||||
}
|
||||
},
|
||||
"source-map": {
|
||||
"version": "0.5.7",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
|
||||
@ -4009,6 +4083,16 @@
|
||||
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
|
||||
"dev": true
|
||||
},
|
||||
"ws": {
|
||||
"version": "8.2.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.2.3.tgz",
|
||||
"integrity": "sha512-wBuoj1BDpC6ZQ1B7DWQBYVLphPWkm8i9Y0/3YdHjHKHiohOJ1ws+3OccDWtH+PoC9DZD5WOTrJvNbWvjS6JWaA=="
|
||||
},
|
||||
"xmlhttprequest-ssl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz",
|
||||
"integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A=="
|
||||
},
|
||||
"yallist": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||
@ -4020,6 +4104,11 @@
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
|
||||
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="
|
||||
},
|
||||
"yeast": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/yeast/-/yeast-0.1.2.tgz",
|
||||
"integrity": "sha1-AI4G2AlDIMNy28L47XagymyKxBk="
|
||||
},
|
||||
"yup": {
|
||||
"version": "0.32.11",
|
||||
"resolved": "https://registry.npmjs.org/yup/-/yup-0.32.11.tgz",
|
||||
|
||||
@ -33,6 +33,7 @@
|
||||
"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"
|
||||
},
|
||||
|
||||
18
src/App.tsx
18
src/App.tsx
@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useContext } from 'react';
|
||||
import { Router, Switch } from 'react-router-dom';
|
||||
import { routes } from './routes.config';
|
||||
|
||||
@ -12,11 +12,27 @@ 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';
|
||||
|
||||
const App = () => {
|
||||
const {
|
||||
uiStore: { history },
|
||||
} = useRootStore();
|
||||
const { socket } = useContext(SocketContext);
|
||||
|
||||
useSocketEvent('connect', () => console.info('[WebSocket]: Connected.'));
|
||||
useSocketEvent('disconnect', () => console.info('[WebSocket]: Disconnected.'));
|
||||
useSocketEvent('connect_error', err => console.error(err));
|
||||
useSocketEvent('user:session', data => {
|
||||
console.log(data);
|
||||
if (socket) {
|
||||
const { sessionId, userId } = data;
|
||||
socket.auth = { sessionId };
|
||||
window.localStorage.setItem('sessionId', sessionId);
|
||||
socket.userId = userId;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Router history={history}>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { Suspense } from 'react';
|
||||
import React, { Suspense, useContext } from 'react';
|
||||
import { observer } from 'mobx-react';
|
||||
import { Redirect } from 'react-router';
|
||||
import { useErrorHandler } from 'react-error-boundary';
|
||||
@ -23,6 +23,11 @@ import NavDesktop from '@educt/components/Nav/Desktop';
|
||||
import { useEffect } from 'react';
|
||||
import { useRootStore } from '@educt/hooks/useRootStore';
|
||||
|
||||
/**
|
||||
* Contexts
|
||||
*/
|
||||
import { SocketContext } from '@educt/contexts';
|
||||
|
||||
type LayoutPropsType = { roles: UserRoleEnum[] | undefined };
|
||||
|
||||
/**
|
||||
@ -30,6 +35,7 @@ type LayoutPropsType = { roles: UserRoleEnum[] | undefined };
|
||||
*/
|
||||
const Layout: React.FC<LayoutPropsType> = ({ children, roles }) => {
|
||||
const { userStore } = useRootStore();
|
||||
const { socket } = useContext(SocketContext);
|
||||
const handleError = useErrorHandler();
|
||||
|
||||
/**
|
||||
@ -39,6 +45,21 @@ 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 />
|
||||
|
||||
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>
|
||||
);
|
||||
@ -1,9 +1,14 @@
|
||||
import { createContext } from 'react';
|
||||
import { ChangeEmailPageContextType, CoursesPageContextType, UsersPageContextType } from '@educt/types';
|
||||
import RootStore from '../stores/RootStore';
|
||||
import { Socket } from 'socket.io-client';
|
||||
|
||||
export const StoreContext = createContext<RootStore | undefined>(undefined);
|
||||
|
||||
export const SocketContext = createContext<{ socket: Socket | null }>({
|
||||
socket: null,
|
||||
});
|
||||
|
||||
export const ChangeEmailPageContext = createContext<ChangeEmailPageContextType>({
|
||||
isCodeSent: false,
|
||||
setIsCodeSent: () => null,
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { useToast } from '@chakra-ui/react';
|
||||
import { SocketContext } from '@educt/contexts';
|
||||
import { IToken } from '@educt/interfaces';
|
||||
import { useContext } from 'react';
|
||||
import { useErrorHandler } from 'react-error-boundary';
|
||||
import useAsync from '../../useAsync';
|
||||
import { useRootStore } from '../../useRootStore';
|
||||
@ -8,6 +10,7 @@ type LoginResultDataType = IToken;
|
||||
|
||||
const useLogin = () => {
|
||||
const { authStore } = useRootStore();
|
||||
const { socket } = useContext(SocketContext);
|
||||
const toast = useToast();
|
||||
const handleError = useErrorHandler();
|
||||
|
||||
@ -20,6 +23,7 @@ const useLogin = () => {
|
||||
isClosable: true,
|
||||
status: 'success',
|
||||
});
|
||||
socket?.connect();
|
||||
return result.data;
|
||||
} catch (error: any) {
|
||||
if (error.response) {
|
||||
|
||||
@ -3,20 +3,45 @@ import { useHistory } from 'react-router-dom';
|
||||
import { useRootStore } from '../../useRootStore';
|
||||
import { useErrorHandler } from 'react-error-boundary';
|
||||
import useAsync from '../../useAsync';
|
||||
import { useContext } from 'react';
|
||||
|
||||
/**
|
||||
* Contexts
|
||||
*/
|
||||
import { SocketContext } from '@educt/contexts';
|
||||
|
||||
type LogoutResultDataType = Record<string, never>;
|
||||
|
||||
const useLogout = () => {
|
||||
const { authStore } = useRootStore();
|
||||
const {
|
||||
authStore,
|
||||
userStore: { me },
|
||||
} = useRootStore();
|
||||
const { socket } = useContext(SocketContext);
|
||||
const history = useHistory();
|
||||
const toast = useToast();
|
||||
const handleError = useErrorHandler();
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
const sessionId = window.localStorage.getItem('sessionId');
|
||||
const result = await authStore.logout();
|
||||
|
||||
history.push('/auth');
|
||||
toast({ title: 'You are logged out.', isClosable: true, status: 'info' });
|
||||
|
||||
/**
|
||||
* Destroy session
|
||||
*/
|
||||
if (sessionId) {
|
||||
if (socket) {
|
||||
socket.auth = { sessionId: undefined };
|
||||
// socket.emit('user:logout', { sessionId });
|
||||
socket.disconnect();
|
||||
}
|
||||
window.localStorage.removeItem('sessionId');
|
||||
}
|
||||
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
|
||||
@ -13,6 +13,7 @@ const useUpdateCategory = () => {
|
||||
const update = async (id: string, data: UpdateCategoryParamsType) => {
|
||||
try {
|
||||
const result = await CategoryServiceInstance.update(id, data);
|
||||
toast({ title: 'Category updated.', status: 'info' });
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
|
||||
33
src/hooks/queries/course/useFetchCourses.ts
Normal file
33
src/hooks/queries/course/useFetchCourses.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { ICourse } from '@educt/interfaces';
|
||||
import { CourseServiceInstance } from '@educt/services';
|
||||
import { FetchCoursesParams } from '@educt/types';
|
||||
import useAsync from '@educt/hooks/useAsync';
|
||||
import { useEffect } from 'react';
|
||||
import { useErrorHandler } from 'react-error-boundary';
|
||||
|
||||
const useFetchCourses = (params?: FetchCoursesParams | undefined) => {
|
||||
const handleError = useErrorHandler();
|
||||
|
||||
const fetchCourses = async (params?: FetchCoursesParams | undefined) => {
|
||||
try {
|
||||
const result = await CourseServiceInstance.fetchAll(params);
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
const { execute: fetch, ...state } = useAsync<
|
||||
Omit<ICourse, 'teacher' | 'students' | 'lessons'>[],
|
||||
Parameters<typeof fetchCourses>
|
||||
>(fetchCourses);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(params);
|
||||
}, []);
|
||||
|
||||
return { ...state };
|
||||
};
|
||||
|
||||
export { useFetchCourses };
|
||||
@ -14,6 +14,7 @@ import { useUpdateLesson } from './lesson/useUpdateLesson';
|
||||
/**
|
||||
* Course
|
||||
*/
|
||||
import { useFetchCourses } from './course/useFetchCourses';
|
||||
import { useCreateCourse } from './course/useCreateCourse';
|
||||
import { useUpdateCourse } from './course/useUpdateCourse';
|
||||
import { useFetchCourse } from './course/useFetchCourse';
|
||||
@ -37,9 +38,15 @@ import { useCreateCategory } from './category/useCreateCategory';
|
||||
import { useDeleteCategory } from './category/useDeleteCategory';
|
||||
import { useUpdateCategory } from './category/useUpdateCategory';
|
||||
|
||||
/**
|
||||
* Stat
|
||||
*/
|
||||
import { useFetchStat } from './stat/useFetchStat';
|
||||
|
||||
export {
|
||||
useLogin,
|
||||
useLogout,
|
||||
useFetchCourses,
|
||||
useCreateCourse,
|
||||
useUpdateCourse,
|
||||
useFetchCourse,
|
||||
@ -57,4 +64,5 @@ export {
|
||||
useDeleteCategory,
|
||||
useUpdateCategory,
|
||||
useFetchCategories,
|
||||
useFetchStat,
|
||||
};
|
||||
|
||||
29
src/hooks/queries/stat/useFetchStat.ts
Normal file
29
src/hooks/queries/stat/useFetchStat.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { StatServiceInstance } from '@educt/services/StatService';
|
||||
import { StatInfoType } from '@educt/types';
|
||||
import { useEffect } from 'react';
|
||||
import useAsync from '@educt/hooks/useAsync';
|
||||
import { useErrorHandler } from 'react-error-boundary';
|
||||
|
||||
const useFetchStat = () => {
|
||||
const handleError = useErrorHandler();
|
||||
|
||||
const fetchStat = async () => {
|
||||
try {
|
||||
const result = await StatServiceInstance.fetchStat();
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
const { execute: fetch, ...state } = useAsync<StatInfoType, Parameters<typeof fetchStat>>(fetchStat);
|
||||
|
||||
useEffect(() => {
|
||||
fetch();
|
||||
}, []);
|
||||
|
||||
return { ...state };
|
||||
};
|
||||
|
||||
export { useFetchStat };
|
||||
18
src/hooks/useSocket.ts
Normal file
18
src/hooks/useSocket.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export const useSocket = (url: string) => {
|
||||
const [socket, setSocket] = useState<Socket | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const socketClient = io(url, { autoConnect: false, withCredentials: true });
|
||||
|
||||
setSocket(socketClient);
|
||||
|
||||
return () => {
|
||||
socket?.close();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return socket;
|
||||
};
|
||||
18
src/hooks/useSocketEvent.ts
Normal file
18
src/hooks/useSocketEvent.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { useContext, useEffect } from 'react';
|
||||
import { SocketContext } from '@educt/contexts';
|
||||
|
||||
export const useSocketEvent = (event: string, callback: (...args: any) => void) => {
|
||||
const { socket } = useContext(SocketContext);
|
||||
|
||||
useEffect(() => {
|
||||
if (!socket) {
|
||||
return;
|
||||
}
|
||||
|
||||
socket.on(event, callback);
|
||||
|
||||
return () => {
|
||||
socket.off(event, callback);
|
||||
};
|
||||
}, [callback, event, socket]);
|
||||
};
|
||||
@ -3,14 +3,16 @@ import ReactDOM from 'react-dom';
|
||||
import { ChakraProvider, ColorModeScript } from '@chakra-ui/react';
|
||||
import App from './App';
|
||||
import { theme } from './theme';
|
||||
import { RootStoreProvider } from './providers';
|
||||
import { RootStoreProvider, SocketContextProvider } from './providers';
|
||||
|
||||
ReactDOM.render(
|
||||
<>
|
||||
<ColorModeScript />
|
||||
<RootStoreProvider>
|
||||
<ChakraProvider theme={theme}>
|
||||
<SocketContextProvider>
|
||||
<App />
|
||||
</SocketContextProvider>
|
||||
</ChakraProvider>
|
||||
</RootStoreProvider>
|
||||
</>,
|
||||
|
||||
@ -13,6 +13,7 @@ export interface IAppConfig {
|
||||
}
|
||||
|
||||
export interface IToken {
|
||||
userId: string;
|
||||
token: string;
|
||||
type: string;
|
||||
expires_at: string;
|
||||
|
||||
95
src/pages/main/components/index.tsx
Normal file
95
src/pages/main/components/index.tsx
Normal file
@ -0,0 +1,95 @@
|
||||
import React from 'react';
|
||||
import moment from 'moment';
|
||||
import {
|
||||
Box,
|
||||
BoxProps,
|
||||
TextProps,
|
||||
Text,
|
||||
FlexProps,
|
||||
Flex,
|
||||
Tag,
|
||||
TagLabel,
|
||||
Icon,
|
||||
Button,
|
||||
useColorModeValue,
|
||||
SimpleGrid,
|
||||
SimpleGridProps,
|
||||
} from '@chakra-ui/react';
|
||||
import { Card } from '@educt/components/Card';
|
||||
import { MdOutlineVideoLibrary, MdOutlineGroup, MdOutlineThumbUpOffAlt, MdOutlinePlayCircle } from 'react-icons/md';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
export const CourseCardList: React.FC<SimpleGridProps> = props => (
|
||||
<SimpleGrid
|
||||
mt='4'
|
||||
templateColumns={{
|
||||
base: '1fr',
|
||||
md: 'repeat(auto-fill, minmax(320px, 1fr))',
|
||||
}}
|
||||
spacing={'6'}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
export const CourseCard: React.FC<BoxProps> = props => <Card py='5' px='6' {...props} />;
|
||||
export const CourseCardHeading: React.FC<BoxProps> = props => (
|
||||
<Box borderLeftWidth={'5px'} borderColor={'blue.500'} pl='2' {...props} />
|
||||
);
|
||||
export const CourseCardTitle: React.FC<TextProps> = props => <Text fontSize={'2xl'} fontWeight={'bold'} {...props} />;
|
||||
export const CourseCardCategory: React.FC = props => (
|
||||
<Flex mt='2'>
|
||||
<Tag size='sm' borderRadius='full' variant={'solid'}>
|
||||
<TagLabel>{props.children}</TagLabel>
|
||||
</Tag>
|
||||
</Flex>
|
||||
);
|
||||
export const CourseCardContent: React.FC<FlexProps> = props => <Flex mt='5' alignItems={'center'} {...props} />;
|
||||
export const CourseCardCreated: React.FC<{ created: string }> = props => (
|
||||
<Box lineHeight={'1.2rem'} mr='14'>
|
||||
<Text fontWeight={'medium'} fontSize={'sm'} color={useColorModeValue('gray.500', 'gray.400')}>
|
||||
Created
|
||||
</Text>
|
||||
<Box as='time' verticalAlign='middle' dateTime={props.created}>
|
||||
{moment(props.created).format('ll')}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
export const CourseCardLessons: React.FC<{ count: string | number }> = props => (
|
||||
<Box>
|
||||
<Flex alignItems={'center'}>
|
||||
<Icon as={MdOutlineVideoLibrary} w={5} h={5} mr='3' />
|
||||
<Box>
|
||||
<Text fontWeight={'medium'} fontSize={'sm'} color={useColorModeValue('gray.500', 'gray.400')}>
|
||||
Total lessons
|
||||
</Text>
|
||||
<Text>{props.count}</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
export const CourseCardFooter: React.FC<FlexProps> = props => (
|
||||
<Flex justifyContent={'space-between'} alignItems={'center'} mt='9' {...props} />
|
||||
);
|
||||
|
||||
export const CourseCardStat: React.FC<{ students: string | number; likes: string | number }> = props => (
|
||||
<Flex>
|
||||
<Flex alignItems={'center'} mr='5'>
|
||||
<Icon as={MdOutlineGroup} mr='2' w={4} h={4} />
|
||||
<Text>{props.students}</Text>
|
||||
</Flex>
|
||||
<Flex alignItems={'center'}>
|
||||
<Icon as={MdOutlineThumbUpOffAlt} mr='2' w={4} h={4} />
|
||||
<Text>{props.likes}</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
export const CourseCardWatch: React.FC<{ id: string }> = props => {
|
||||
const history = useHistory();
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Button onClick={() => history.push(`/course/${props.id}`)} leftIcon={<MdOutlinePlayCircle />} size='sm'>
|
||||
Watch
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@ -1,17 +1,129 @@
|
||||
import React from 'react';
|
||||
import { Box, Heading } from '@chakra-ui/react';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Flex, Box, Text, Link, SimpleGrid, Divider } from '@chakra-ui/react';
|
||||
import { Link as ReactRouterLink } from 'react-router-dom';
|
||||
import { IPageProps } from '@educt/interfaces';
|
||||
import { PageContent, PageHeading, PageWrapper } from '@educt/components/PageElements';
|
||||
import { Stat, StatContent, StatIcon, StatLabel, StatNumber } from '@educt/components/Stat';
|
||||
import { MdOutlineCollectionsBookmark, MdOutlineGroup, MdOutlineMessage, MdOutlineVideoLibrary } from 'react-icons/md';
|
||||
import { useRootStore } from '@educt/hooks/useRootStore';
|
||||
import LoadingPage from '@educt/components/LoadingPage';
|
||||
import {
|
||||
CourseCard,
|
||||
CourseCardCategory,
|
||||
CourseCardCreated,
|
||||
CourseCardHeading,
|
||||
CourseCardContent,
|
||||
CourseCardLessons,
|
||||
CourseCardTitle,
|
||||
CourseCardFooter,
|
||||
CourseCardWatch,
|
||||
CourseCardStat,
|
||||
CourseCardList,
|
||||
} from './components';
|
||||
import { observer } from 'mobx-react';
|
||||
import { useFetchCourses, useFetchStat } from '@educt/hooks/queries';
|
||||
import { useSocketEvent } from '@educt/hooks/useSocketEvent';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Main page
|
||||
*/
|
||||
const MainPage: React.FC<IPageProps> = () => {
|
||||
const {
|
||||
userStore: { me },
|
||||
} = useRootStore();
|
||||
const { data: courses } = useFetchCourses({ limit: 3 });
|
||||
const { data: stat } = useFetchStat();
|
||||
const [online, setOnline] = useState<number>(0);
|
||||
|
||||
/**
|
||||
* Update online state on socket event
|
||||
*/
|
||||
useSocketEvent('user:online', online => setOnline(online));
|
||||
|
||||
useSocketEvent('user:connected', user => console.table(user));
|
||||
|
||||
useEffect(() => {
|
||||
if (stat) {
|
||||
setOnline(stat.online);
|
||||
}
|
||||
}, [stat]);
|
||||
|
||||
if (me === null || courses === null || stat === null) return <LoadingPage />;
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Heading as='h1'>Main page</Heading>
|
||||
<PageWrapper>
|
||||
<PageHeading heading={`👋 Hello ${me.first_name},`} description="This what we've got today for you." />
|
||||
<PageContent>
|
||||
<SimpleGrid columns={{ base: 1, md: 2, lg: 4 }} spacing='6'>
|
||||
<Stat>
|
||||
<StatLabel>Online</StatLabel>
|
||||
<StatContent>
|
||||
<StatNumber>{online}</StatNumber>
|
||||
<StatIcon icon={MdOutlineGroup} />
|
||||
</StatContent>
|
||||
</Stat>
|
||||
|
||||
<Stat>
|
||||
<StatLabel>Unread messages</StatLabel>
|
||||
<StatContent>
|
||||
<StatNumber>
|
||||
<Link as={ReactRouterLink} to='/messages'>
|
||||
1
|
||||
</Link>
|
||||
</StatNumber>
|
||||
<StatIcon icon={MdOutlineMessage} />
|
||||
</StatContent>
|
||||
</Stat>
|
||||
|
||||
<Stat>
|
||||
<StatLabel>Courses</StatLabel>
|
||||
<StatContent>
|
||||
<StatNumber>{stat.courses_count}</StatNumber>
|
||||
<StatIcon icon={MdOutlineCollectionsBookmark} />
|
||||
</StatContent>
|
||||
</Stat>
|
||||
|
||||
<Stat>
|
||||
<StatLabel>Lessons</StatLabel>
|
||||
<StatContent>
|
||||
<StatNumber>{stat.lessons_count}</StatNumber>
|
||||
<StatIcon icon={MdOutlineVideoLibrary} />
|
||||
</StatContent>
|
||||
</Stat>
|
||||
</SimpleGrid>
|
||||
<Box my='8'>
|
||||
<Flex justifyContent={'space-between'}>
|
||||
<Text fontWeight={'medium'}>Recently added</Text>
|
||||
<Link as={ReactRouterLink} fontWeight={'medium'} to='/courses'>
|
||||
See all
|
||||
</Link>
|
||||
</Flex>
|
||||
<CourseCardList>
|
||||
{courses.map(course => (
|
||||
<CourseCard key={course.id}>
|
||||
<CourseCardHeading borderColor={course.color?.hex}>
|
||||
<CourseCardTitle>{course.title}</CourseCardTitle>
|
||||
<CourseCardCategory>{course.category.title}</CourseCardCategory>
|
||||
</CourseCardHeading>
|
||||
|
||||
<CourseCardContent>
|
||||
<CourseCardCreated created={course.created_at} />
|
||||
<CourseCardLessons count={course.lessons_count} />
|
||||
</CourseCardContent>
|
||||
|
||||
<CourseCardFooter>
|
||||
<CourseCardStat students={course.students_count} likes={course.likes_count} />
|
||||
<CourseCardWatch id={course.id} />
|
||||
</CourseCardFooter>
|
||||
</CourseCard>
|
||||
))}
|
||||
</CourseCardList>
|
||||
</Box>
|
||||
<Divider />
|
||||
</PageContent>
|
||||
</PageWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainPage;
|
||||
export default observer(MainPage);
|
||||
|
||||
@ -69,7 +69,7 @@ export const ProfileDescription: React.FC<IProfileDescriptionProps> = props => {
|
||||
);
|
||||
};
|
||||
|
||||
export const ProfileSignOutButton: React.FC<ButtonProps> = props => {
|
||||
export const ProfileSignOutButton: React.FC<ButtonProps> = () => {
|
||||
const { logout } = useLogout();
|
||||
|
||||
return (
|
||||
|
||||
@ -1,9 +1,16 @@
|
||||
import React, { useState, ReactNode } from 'react';
|
||||
import { ChangeEmailPageContext, CoursesPageContext, StoreContext, UsersPageContext } from '@educt/contexts';
|
||||
import {
|
||||
ChangeEmailPageContext,
|
||||
CoursesPageContext,
|
||||
SocketContext,
|
||||
StoreContext,
|
||||
UsersPageContext,
|
||||
} from '@educt/contexts';
|
||||
import { ConfirmEmailDataType, SearchingRoleStateType } from '@educt/types';
|
||||
import RootStore from '@educt/stores/RootStore';
|
||||
import { ICategory, ICourse, IUser } from '@educt/interfaces';
|
||||
import { ICategory } from '@educt/interfaces';
|
||||
import { CourseStatusEnum } from '@educt/enums';
|
||||
import { useSocket } from '@educt/hooks/useSocket';
|
||||
|
||||
/**
|
||||
* Root store context provider
|
||||
@ -14,6 +21,15 @@ export const RootStoreProvider = ({ children }: { children: ReactNode }) => {
|
||||
return <StoreContext.Provider value={store}>{children}</StoreContext.Provider>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Socket context provider
|
||||
*/
|
||||
export const SocketContextProvider: React.FC = ({ children }) => {
|
||||
const socket = useSocket('ws://localhost:3333');
|
||||
|
||||
return <SocketContext.Provider value={{ socket }}>{children}</SocketContext.Provider>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Change email page context provider
|
||||
*/
|
||||
|
||||
@ -24,6 +24,7 @@ class CourseService {
|
||||
params: {
|
||||
status: params?.status,
|
||||
category_id: params?.category_id,
|
||||
limit: params?.limit,
|
||||
},
|
||||
});
|
||||
return result.data;
|
||||
|
||||
24
src/services/StatService.ts
Normal file
24
src/services/StatService.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { AxiosInstance } from 'axios';
|
||||
import { ApiServiceInstance } from '.';
|
||||
import { IApiRespose } from '@educt/interfaces';
|
||||
import { StatInfoType } from '@educt/types';
|
||||
|
||||
class StatService {
|
||||
public api: AxiosInstance;
|
||||
|
||||
constructor(api: AxiosInstance) {
|
||||
this.api = api;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get basic stat info about system
|
||||
*
|
||||
* @returns Stat info
|
||||
*/
|
||||
public async fetchStat(): Promise<IApiRespose<StatInfoType>> {
|
||||
const result = await this.api.get('/v1/stat');
|
||||
return result.data;
|
||||
}
|
||||
}
|
||||
|
||||
export const StatServiceInstance = new StatService(ApiServiceInstance.api);
|
||||
14
src/types/index.d.ts
vendored
14
src/types/index.d.ts
vendored
@ -40,6 +40,12 @@ export type LessonProgress = {
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type StatInfoType = {
|
||||
courses_count: string;
|
||||
lessons_count: string;
|
||||
online: number;
|
||||
};
|
||||
|
||||
export type LessonVideoType = {
|
||||
id: number;
|
||||
client_name: string;
|
||||
@ -109,15 +115,9 @@ export type CoursesPageContextType = {
|
||||
setCourseStatus: React.Dispatch<React.SetStateAction<CourseStatusEnum | undefined>>;
|
||||
};
|
||||
export type FetchCoursesParams = {
|
||||
/**
|
||||
* Get courses by status
|
||||
*/
|
||||
status?: CourseStatusEnum;
|
||||
|
||||
/**
|
||||
* Get courses with category by id
|
||||
*/
|
||||
category_id?: string;
|
||||
limit?: number;
|
||||
};
|
||||
export type FetchUsersParamsType = {
|
||||
page?: number;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user