mirror of
https://github.com/sergeyyarkov/educt-client.git
synced 2026-10-11 10:19:22 +03:00
feat: ui store & updated nav
This commit is contained in:
parent
e1fd995d53
commit
f48c1d2a2c
11
src/App.tsx
11
src/App.tsx
@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Switch } from 'react-router-dom';
|
||||
import { Router, Switch } from 'react-router-dom';
|
||||
|
||||
/**
|
||||
* Components
|
||||
@ -14,17 +14,22 @@ import MainPage from 'pages/main';
|
||||
import CoursesPage from 'pages/courses';
|
||||
import MessagesPage from 'pages/messages';
|
||||
import ProfilePage from 'pages/profile';
|
||||
import ControlPage from 'pages/control';
|
||||
import AuthPage from 'pages/auth';
|
||||
import NotFoundPage from 'pages/404';
|
||||
import { UserRoleEnum } from 'enums';
|
||||
import { useRootStore } from 'hooks/useRootStore';
|
||||
|
||||
const App = () => {
|
||||
const { uiStore } = useRootStore();
|
||||
return (
|
||||
<Router>
|
||||
<Router history={uiStore.history}>
|
||||
<Switch>
|
||||
<PrivateRoute exact path='/' component={MainPage} title='Main' />
|
||||
<PrivateRoute exact path='/' component={MainPage} title='Home' />
|
||||
<PrivateRoute path='/courses' component={CoursesPage} title='Courses' />
|
||||
<PrivateRoute path='/messages' component={MessagesPage} title='Messages' />
|
||||
<PrivateRoute path='/profile' component={ProfilePage} title='Profile' />
|
||||
<PrivateRoute path='/control' component={ControlPage} title='Control' roles={[UserRoleEnum.ADMIN]} />
|
||||
<PublicRoute path='/auth' component={AuthPage} />
|
||||
<PublicRoute component={NotFoundPage} />
|
||||
</Switch>
|
||||
|
||||
@ -2,7 +2,6 @@ import React from 'react';
|
||||
import { MdAccountCircle, MdVpnKey } from 'react-icons/md';
|
||||
import { FormControl, FormLabel, InputGroup, Input, InputLeftElement, Button, Icon, useToast } from '@chakra-ui/react';
|
||||
import { useRootStore } from 'hooks/useRootStore';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { observer } from 'mobx-react';
|
||||
import { SubmitHandler, useForm } from 'react-hook-form';
|
||||
import { useErrorHandler } from 'react-error-boundary';
|
||||
@ -15,14 +14,12 @@ type AuthInputs = {
|
||||
const AuthForm: React.FC = () => {
|
||||
const { register, reset, handleSubmit } = useForm<AuthInputs>();
|
||||
const { authStore } = useRootStore();
|
||||
const history = useHistory();
|
||||
const handleError = useErrorHandler();
|
||||
const toast = useToast();
|
||||
|
||||
const onSubmit: SubmitHandler<AuthInputs> = async data => {
|
||||
try {
|
||||
await authStore.login(data.login, data.password);
|
||||
history.push('/');
|
||||
toast({
|
||||
title: `👋 Welcome back!`,
|
||||
description: 'You are successfully logged in.',
|
||||
|
||||
@ -5,6 +5,7 @@ import UserInfo from './User/UserInfo';
|
||||
import UserNotifications from './User/UserNotifications';
|
||||
import useWindowDimensions from 'hooks/useWindowDimensions';
|
||||
import NavMobile from '../Nav/Mobile/Nav';
|
||||
import { observer } from 'mobx-react';
|
||||
|
||||
const Header: React.FC = () => {
|
||||
const { isDesktop } = useWindowDimensions();
|
||||
@ -45,4 +46,4 @@ const Header: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
export default observer(Header);
|
||||
|
||||
@ -18,7 +18,7 @@ import { MdSettings, MdExitToApp } from 'react-icons/md';
|
||||
import { observer } from 'mobx-react';
|
||||
import { useRootStore } from 'hooks/useRootStore';
|
||||
import { useHistory, Link } from 'react-router-dom';
|
||||
import UserBadge from 'components/Header/User/UserBadge';
|
||||
import UserBadge from 'components/User/UserBadge';
|
||||
|
||||
const UserInfo: React.FC = () => {
|
||||
const { authStore, userStore } = useRootStore();
|
||||
@ -36,7 +36,7 @@ const UserInfo: React.FC = () => {
|
||||
};
|
||||
|
||||
if (userStore.me === null) {
|
||||
return <Skeleton width='260px' borderRadius='md' />;
|
||||
return <Skeleton width='260px' height='40px' borderRadius='md' />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@ -1,9 +1,18 @@
|
||||
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 '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}
|
||||
@ -30,4 +39,4 @@ const UserNotifications: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default UserNotifications;
|
||||
export default observer(UserNotifications);
|
||||
|
||||
@ -6,6 +6,7 @@ import { observer } from 'mobx-react';
|
||||
import { useRootStore } from 'hooks/useRootStore';
|
||||
import { useErrorHandler } from 'react-error-boundary';
|
||||
import { ColorModeSwitcher } from 'components/ColorModeSwitcher/ColorModeSwitcher';
|
||||
import LoadingPage from 'components/Loading/LoadingPage';
|
||||
|
||||
/**
|
||||
*
|
||||
@ -37,7 +38,7 @@ const Layout: React.FC = ({ children }) => {
|
||||
}}
|
||||
>
|
||||
<Box as='main' mr='auto' ml='auto' maxW='85rem' pl={5} pr={5} pt='2rem' pb='2rem'>
|
||||
{children}
|
||||
{userStore.me === null ? <LoadingPage /> : children}
|
||||
<ColorModeSwitcher position='absolute' right={0} bottom={0} margin={5} />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
14
src/components/Loading/LoadingPage.tsx
Normal file
14
src/components/Loading/LoadingPage.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import { Skeleton } from '@chakra-ui/skeleton';
|
||||
import { Box } from '@chakra-ui/layout';
|
||||
|
||||
const LoadingPage: React.FC = () => {
|
||||
return (
|
||||
<Box>
|
||||
<Skeleton height='20px' width='200px' mb='10' borderRadius='md' />
|
||||
<Skeleton height='43px' width='400px' borderRadius='md' />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadingPage;
|
||||
@ -1,8 +1,7 @@
|
||||
import React from 'react';
|
||||
import NavLink from './NavLink';
|
||||
import config from 'config';
|
||||
import { Flex, Box } from '@chakra-ui/react';
|
||||
import useWindowDimensions from 'hooks/useWindowDimensions';
|
||||
import NavList from '../NavList';
|
||||
|
||||
/**
|
||||
*
|
||||
@ -24,9 +23,7 @@ const Nav: React.FC = () => {
|
||||
height='calc(100vh - 4rem)'
|
||||
padding='5'
|
||||
>
|
||||
{config.links.map((link, i) => (
|
||||
<NavLink link={link} key={i} />
|
||||
))}
|
||||
<NavList />
|
||||
</Flex>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
import { observer } from 'mobx-react';
|
||||
import config from 'config';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useRootStore } from 'hooks/useRootStore';
|
||||
|
||||
@ -9,7 +8,7 @@ import { Flex, Box, Text } from '@chakra-ui/layout';
|
||||
import { Drawer, DrawerOverlay, DrawerContent, DrawerBody } from '@chakra-ui/modal';
|
||||
import { HamburgerIcon } from '@chakra-ui/icons';
|
||||
import { useDisclosure, IconButton, useColorMode, Skeleton } from '@chakra-ui/react';
|
||||
import NavLink from '../Desktop/NavLink';
|
||||
import NavList from '../NavList';
|
||||
|
||||
const NavMobile: React.FC = () => {
|
||||
const { userStore } = useRootStore();
|
||||
@ -24,9 +23,7 @@ const NavMobile: React.FC = () => {
|
||||
<DrawerContent backgroundColor={colorMode === 'dark' ? 'gray.800' : 'white'}>
|
||||
<DrawerBody>
|
||||
<Flex as='nav' flexDirection='column' alignItems='flex-start' textDecoration='none'>
|
||||
{config.links.map((link, i) => (
|
||||
<NavLink link={link} key={i} onCloseDrawer={onClose} />
|
||||
))}
|
||||
<NavList onCloseDrawer={onClose} />
|
||||
</Flex>
|
||||
{userStore.me ? (
|
||||
<Box
|
||||
|
||||
@ -2,18 +2,19 @@ import React from 'react';
|
||||
import { Box, useColorMode } from '@chakra-ui/react';
|
||||
import type { LinkType } from 'interfaces';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { observer } from 'mobx-react';
|
||||
import { useRootStore } from 'hooks/useRootStore';
|
||||
|
||||
type ActiveLinkState = {
|
||||
title: string | null;
|
||||
export type NavLinkProps = {
|
||||
link: LinkType;
|
||||
onCloseDrawer?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* NavLink component
|
||||
* Returns the link component for navigation.
|
||||
*/
|
||||
const NavLink: React.FC<{ link: LinkType; onCloseDrawer?: () => void }> = ({ link, onCloseDrawer }) => {
|
||||
const [activeLink, setActiveLink] = React.useState<ActiveLinkState>({ title: null });
|
||||
const NavLink: React.FC<NavLinkProps> = ({ link, onCloseDrawer }) => {
|
||||
const { uiStore } = useRootStore();
|
||||
const { colorMode } = useColorMode();
|
||||
const history = useHistory();
|
||||
|
||||
@ -27,8 +28,6 @@ const NavLink: React.FC<{ link: LinkType; onCloseDrawer?: () => void }> = ({ lin
|
||||
return (
|
||||
<Box
|
||||
as='a'
|
||||
onMouseEnter={() => setActiveLink({ title: link.title })}
|
||||
onMouseLeave={() => setActiveLink({ title: null })}
|
||||
onClick={handleClick}
|
||||
display='flex'
|
||||
margin='3px 0'
|
||||
@ -39,12 +38,8 @@ const NavLink: React.FC<{ link: LinkType; onCloseDrawer?: () => void }> = ({ lin
|
||||
cursor='pointer'
|
||||
>
|
||||
<Box
|
||||
color={history.location.pathname === link.location || activeLink.title === link.title ? 'blue.400' : ''}
|
||||
backgroundColor={
|
||||
activeLink.title === link.title || history.location.pathname === link.location
|
||||
? `${colorMode === 'dark' ? 'gray.700' : 'gray.100'}`
|
||||
: ''
|
||||
}
|
||||
color={uiStore.location === link.location ? 'blue.400' : ''}
|
||||
backgroundColor={uiStore.location === link.location ? `${colorMode === 'dark' ? 'gray.700' : 'gray.100'}` : ''}
|
||||
alignItems='center'
|
||||
borderRadius='9999px'
|
||||
display='flex'
|
||||
@ -59,4 +54,4 @@ const NavLink: React.FC<{ link: LinkType; onCloseDrawer?: () => void }> = ({ lin
|
||||
);
|
||||
};
|
||||
|
||||
export default NavLink;
|
||||
export default observer(NavLink);
|
||||
34
src/components/Nav/NavList.tsx
Normal file
34
src/components/Nav/NavList.tsx
Normal file
@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import { observer } from 'mobx-react';
|
||||
import config from 'config';
|
||||
import NavLink from './NavLink';
|
||||
import { useRootStore } from 'hooks/useRootStore';
|
||||
import { userHasRoles } from 'helpers';
|
||||
import { UserRoleEnum } from 'enums';
|
||||
|
||||
/**
|
||||
* Render a navigation list of links
|
||||
*/
|
||||
const NavList: React.FC<{ onCloseDrawer?: () => void }> = ({ onCloseDrawer }) => {
|
||||
const { userStore } = useRootStore();
|
||||
|
||||
return (
|
||||
<>
|
||||
{config.links.map((link, i) => {
|
||||
if (link.public) {
|
||||
return <NavLink link={link} key={i} onCloseDrawer={onCloseDrawer} />;
|
||||
} else {
|
||||
/**
|
||||
* Render not public link
|
||||
*/
|
||||
if (userStore.me && userHasRoles(userStore.me.roles, [UserRoleEnum.ADMIN])) {
|
||||
return <NavLink link={link} key={i} onCloseDrawer={onCloseDrawer} />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default observer(NavList);
|
||||
@ -1,18 +1,19 @@
|
||||
import React from 'react';
|
||||
import Helmet from 'react-helmet';
|
||||
import Layout from 'components/Layout/Layout';
|
||||
import Layout from '../Layout/Layout';
|
||||
import { Route, Redirect } from 'react-router-dom';
|
||||
import { IPrivateRouteProps } from 'interfaces';
|
||||
import { useRootStore } from 'hooks/useRootStore';
|
||||
import { observer } from 'mobx-react';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import ErrorFallback from '../ErrorFallback';
|
||||
import config from 'config';
|
||||
|
||||
/**
|
||||
* Checks if the user is loggedIn by reactive variable and if not,
|
||||
* then a redirect to "/auth" route.
|
||||
*/
|
||||
const PrivateRoute: React.FC<IPrivateRouteProps> = ({ component: Component, title, ...options }) => {
|
||||
const PrivateRoute: React.FC<IPrivateRouteProps> = ({ component: Component, title, roles, ...options }) => {
|
||||
const { authStore } = useRootStore();
|
||||
|
||||
return (
|
||||
@ -23,9 +24,11 @@ const PrivateRoute: React.FC<IPrivateRouteProps> = ({ component: Component, titl
|
||||
<ErrorBoundary FallbackComponent={ErrorFallback}>
|
||||
<Layout>
|
||||
<Helmet>
|
||||
<title>{title} • App </title>
|
||||
<title>
|
||||
{title} • {config.metaData.appName}{' '}
|
||||
</title>
|
||||
</Helmet>
|
||||
<Component {...props} title={title} />
|
||||
<Component {...props} title={title} roles={roles} />
|
||||
</Layout>
|
||||
</ErrorBoundary>
|
||||
) : (
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { MdAccountCircle, MdCollectionsBookmark, MdHome, MdMessage } from 'react-icons/md';
|
||||
import { MdAccountCircle, MdBuild, MdCollectionsBookmark, MdHome, MdMessage } from 'react-icons/md';
|
||||
import { IAppConfig } from './interfaces';
|
||||
|
||||
const config: IAppConfig = {
|
||||
@ -7,10 +7,11 @@ const config: IAppConfig = {
|
||||
appDescription: 'Learn management system',
|
||||
},
|
||||
links: [
|
||||
{ location: '/', title: 'Home', icon: MdHome },
|
||||
{ location: '/courses', title: 'Courses', icon: MdCollectionsBookmark },
|
||||
{ location: '/messages', title: 'Messages', icon: MdMessage },
|
||||
{ location: '/profile', title: 'Profile', icon: MdAccountCircle },
|
||||
{ location: '/', title: 'Home', public: true, icon: MdHome },
|
||||
{ location: '/courses', title: 'Courses', public: true, icon: MdCollectionsBookmark },
|
||||
{ location: '/messages', title: 'Messages', public: true, icon: MdMessage },
|
||||
{ location: '/profile', title: 'Profile', public: true, icon: MdAccountCircle },
|
||||
{ location: '/control', title: 'Control', public: false, icon: MdBuild },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
14
src/helpers/index.ts
Normal file
14
src/helpers/index.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { UserRoleEnum } from 'enums';
|
||||
import { IUserRole } from 'interfaces';
|
||||
|
||||
export function userHasRoles(userRoles: IUserRole[], roles: UserRoleEnum[]): boolean {
|
||||
for (let i = 0; i < roles.length; i += 1) {
|
||||
if (!userRoles.map(r => r.slug).includes(roles[i])) {
|
||||
/**
|
||||
* Unable to find current role in user roles
|
||||
*/
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@ -6,6 +6,7 @@ import { UserRoleEnum } from '../enums';
|
||||
export type LinkType = {
|
||||
location: string;
|
||||
title: string;
|
||||
public: boolean;
|
||||
icon: IconType;
|
||||
};
|
||||
|
||||
@ -17,6 +18,14 @@ export interface IAppConfig {
|
||||
links: LinkType[];
|
||||
}
|
||||
|
||||
export interface IUser {
|
||||
id: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
roles: IUserRole[];
|
||||
}
|
||||
|
||||
export interface IUserRole {
|
||||
id: number;
|
||||
name: string;
|
||||
@ -49,11 +58,13 @@ export interface IUserResult extends IDataResult {
|
||||
|
||||
export interface IPageProps extends RouteComponentProps {
|
||||
title?: string;
|
||||
roles?: UserRoleEnum[];
|
||||
}
|
||||
|
||||
export interface IPrivateRouteProps extends RouteProps {
|
||||
component: React.FC<IPageProps>;
|
||||
title?: string;
|
||||
roles?: UserRoleEnum[];
|
||||
}
|
||||
|
||||
export interface IPublicRouteProps extends RouteProps {
|
||||
|
||||
39
src/pages/control.tsx
Normal file
39
src/pages/control.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import { Box, Heading, Breadcrumb, BreadcrumbItem, BreadcrumbLink, Grid } from '@chakra-ui/react';
|
||||
|
||||
import { IPageProps } from 'interfaces';
|
||||
import { observer } from 'mobx-react';
|
||||
import { useRootStore } from 'hooks/useRootStore';
|
||||
import { userHasRoles } from 'helpers';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
|
||||
/**
|
||||
* Control page
|
||||
*/
|
||||
const ControlPage: React.FC<IPageProps> = ({ title, roles }) => {
|
||||
const { userStore } = useRootStore();
|
||||
|
||||
if (userStore.me && roles && !userHasRoles(userStore.me.roles, roles)) {
|
||||
return <Redirect to='/404' />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Breadcrumb fontWeight='medium' fontSize='sm'>
|
||||
<BreadcrumbItem isCurrentPage>
|
||||
<BreadcrumbLink href='/'>{title}</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
</Breadcrumb>
|
||||
<Box mt='10'>
|
||||
<Box>
|
||||
<Heading as='h1'>Control page</Heading>
|
||||
<Grid templateColumns='3fr 1fr' mt='25px'>
|
||||
...
|
||||
</Grid>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default observer(ControlPage);
|
||||
@ -12,7 +12,7 @@ const MainPage: React.FC<IPageProps> = ({ title }) => {
|
||||
<>
|
||||
<Breadcrumb fontWeight='medium' fontSize='sm'>
|
||||
<BreadcrumbItem isCurrentPage>
|
||||
<BreadcrumbLink href='/'>Main page</BreadcrumbLink>
|
||||
<BreadcrumbLink href='/'>{title}</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
</Breadcrumb>
|
||||
<Box mt='10'>
|
||||
|
||||
@ -50,6 +50,7 @@ export default class AuthStore {
|
||||
const result = await this.authService.requestLogout();
|
||||
|
||||
this.setIsLoggedIn(false);
|
||||
this.root.userStore.reset();
|
||||
Cookies.remove('logged_in');
|
||||
return result;
|
||||
} catch (error) {
|
||||
|
||||
@ -11,8 +11,11 @@ import ApiService from 'services/ApiService';
|
||||
*/
|
||||
import AuthStore from './AuthStore';
|
||||
import UserStore from './UserStore';
|
||||
import UIStore from './UIStore';
|
||||
|
||||
export default class RootStore {
|
||||
public uiStore: UIStore;
|
||||
|
||||
public authStore: AuthStore;
|
||||
|
||||
public userStore: UserStore;
|
||||
@ -32,6 +35,7 @@ export default class RootStore {
|
||||
/**
|
||||
* Stores
|
||||
*/
|
||||
this.uiStore = new UIStore(this);
|
||||
this.authStore = new AuthStore(this, this.apiService.api);
|
||||
this.userStore = new UserStore(this, this.apiService.api);
|
||||
|
||||
|
||||
27
src/stores/UIStore.ts
Normal file
27
src/stores/UIStore.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { makeAutoObservable } from 'mobx';
|
||||
import { createBrowserHistory } from 'history';
|
||||
import RootStore from './RootStore';
|
||||
|
||||
export default class UIStore {
|
||||
public root: RootStore;
|
||||
|
||||
public location: string = document.location.pathname;
|
||||
|
||||
public history: ReturnType<typeof createBrowserHistory>;
|
||||
|
||||
constructor(root: RootStore) {
|
||||
this.root = root;
|
||||
this.history = createBrowserHistory();
|
||||
makeAutoObservable(this);
|
||||
|
||||
this.history.listen(({ pathname }) => {
|
||||
console.log('path chaged');
|
||||
|
||||
this.location = pathname;
|
||||
});
|
||||
}
|
||||
|
||||
public setLocation(location: string) {
|
||||
this.location = location;
|
||||
}
|
||||
}
|
||||
@ -1,18 +1,10 @@
|
||||
import { AxiosInstance } from 'axios';
|
||||
import { makeAutoObservable, runInAction } from 'mobx';
|
||||
import { IUserResult, IUserRole } from 'interfaces';
|
||||
import { IUserResult, IUser } from 'interfaces';
|
||||
|
||||
import UserService from 'services/UserService';
|
||||
import RootStore from './RootStore';
|
||||
|
||||
export interface IUser {
|
||||
id: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
roles: IUserRole[];
|
||||
}
|
||||
|
||||
export default class UserStore {
|
||||
public root: RootStore;
|
||||
|
||||
@ -41,4 +33,10 @@ export default class UserStore {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
runInAction(() => {
|
||||
this.me = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user