Merge branch 'feature/profile-page' into develop

This commit is contained in:
Sergey Yarkov 2022-02-04 22:49:03 +03:00
commit 6f855af3db
49 changed files with 991 additions and 460 deletions

View File

@ -1,4 +1,4 @@
import React, { useContext } from 'react';
import React, { useContext, useEffect } from 'react';
import { Router, Switch } from 'react-router-dom';
import { routes } from './routes.config';

View File

@ -53,7 +53,7 @@ const AddStudentsModal: React.FC<AddStudentsModalPropsType> = ({
icon={MdGroup}
heading='Add new students'
description='Attach new students to current course.'
confirmText='Add students'
confirmText='Add'
isOpen={isOpen}
onClose={onClose}
onProceed={handleAttachStudents}

View File

@ -1,17 +1,23 @@
import React from 'react';
import { BoxProps, Box, Flex, Heading, Text, Divider } from '@chakra-ui/react';
interface IPageHeadingProps extends BoxProps {
interface IPageComposition {
Heading: React.FC<IPageHeadingProps & BoxProps>;
Content: React.FC<BoxProps>;
Footer: React.FC<BoxProps>;
}
const Page: React.FC<BoxProps> & IPageComposition = props => {
return <Box {...props}>{props.children}</Box>;
};
interface IPageHeadingProps {
headingPrefix?: JSX.Element;
heading: string;
description?: string;
}
export const PageWrapper: React.FC<BoxProps> = props => {
return <Box {...props}>{props.children}</Box>;
};
export const PageHeading: React.FC<IPageHeadingProps> = props => {
Page.Heading = props => {
const { headingPrefix, heading, description, ...boxProps } = props;
return (
<Box {...boxProps}>
@ -26,15 +32,17 @@ export const PageHeading: React.FC<IPageHeadingProps> = props => {
);
};
export const PageContent: React.FC<BoxProps> = props => (
Page.Content = props => (
<Box mt='4' {...props}>
{props.children}
</Box>
);
export const PageFooter: React.FC<BoxProps> = props => (
Page.Footer = props => (
<Box mt='4' {...props}>
<Divider m='20px 0' />
{props.children}
</Box>
);
export { Page };

View 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 && <ChakraAvatarBadge boxSize='0.75em' bg={onlineColor} />}
</ChakraAvatar>
{props.children}
</Flex>
);
};
export { Avatar };

View 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(`/chat/${userid}`);
return (
<Button onClick={handleChat} mt='5' variant={'outline'} size={'sm'} leftIcon={<MdOutlineMessage />} {...props}>
Chat
</Button>
);
};
export { ChatButton };

View 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 };

View 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 };

View File

@ -0,0 +1 @@
export { Base } from './Base';

View 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 };

View File

@ -0,0 +1,31 @@
import React from 'react';
import moment from 'moment';
import { Flex, Text, Icon, Divider } from '@chakra-ui/react';
import { CalendarIcon, RepeatClockIcon } from '@chakra-ui/icons';
import type { IDetailsProps } from '.';
const Details: React.FC<IDetailsProps> = ({ registered, lastLogin }) => {
return (
<>
<Flex my={'3'} justifyContent={{ base: 'center', md: 'flex-start' }}>
<Flex>
<Flex alignItems={'center'} mr='5'>
<Icon as={CalendarIcon} color='gray.500' />
<Text fontWeight={'medium'} fontSize={'sm'} color='gray.500' ml='3'>
Joined: {new Date(registered).toLocaleDateString()}
</Text>
</Flex>
<Flex alignItems={'center'} mr='5'>
<Icon as={RepeatClockIcon} color='gray.500' />
<Text fontWeight={'medium'} fontSize={'sm'} color='gray.500' ml='3'>
Last login: {lastLogin ? `${moment(lastLogin).fromNow()}` : 'Never'}
</Text>
</Flex>
</Flex>
</Flex>
<Divider my='3' />
</>
);
};
export { Details };

View File

@ -0,0 +1,19 @@
import React from 'react';
import UserBadge from '@educt/components/UserBadge';
import { Flex, Box, Heading as ChakraHeading } from '@chakra-ui/react';
import type { IHeadingProps } from '.';
const Heading: React.FC<IHeadingProps> = ({ fullname, roles }) => {
return (
<Flex justifyContent={{ base: 'center', md: 'flex-start' }} mt={{ base: '5', md: '0' }}>
<ChakraHeading as='h2' fontSize={'xl'} fontWeight={'bold'}>
{fullname}&nbsp;&bull;&nbsp;
</ChakraHeading>
<Box>
<UserBadge roles={roles} />
</Box>
</Flex>
);
};
export { Heading };

View File

@ -0,0 +1,12 @@
import React from 'react';
import { Box } from '@chakra-ui/react';
const Info: React.FC = props => {
return (
<Box ml={{ base: '0', md: '10' }} {...props}>
{props.children}
</Box>
);
};
export { Info };

View File

@ -0,0 +1,21 @@
import { IUser } from '@educt/interfaces';
import { Info } from './Info';
import { Heading } from './Heading';
import { Details } from './Details';
import { About } from './About';
export interface IHeadingProps {
fullname: string;
roles: IUser['roles'];
}
export interface IDetailsProps {
registered: string;
lastLogin: IUser['last_login'];
}
export interface IAboutProps {
about: IUser['about'];
}
export { Info, Heading, Details, About };

View File

@ -0,0 +1,8 @@
import React from 'react';
import { Flex } from '@chakra-ui/react';
const Entry: React.FC = props => {
return <Flex flexDir={{ base: 'column', md: 'row' }}>{props.children}</Flex>;
};
export { Entry };

View File

@ -0,0 +1,12 @@
import React from 'react';
import { Flex } from '@chakra-ui/react';
const EntryContent: React.FC = props => {
return (
<Flex flexDir={'column'} w='full' mt={{ base: '6', md: '0' }}>
{props.children}
</Flex>
);
};
export { EntryContent };

View File

@ -0,0 +1,196 @@
import React from 'react';
import {
Box,
FormControl,
FormHelperText,
FormLabel,
Input,
InputGroup,
InputRightElement,
InputProps,
Button,
Textarea,
Text,
TextareaProps,
Select,
SelectProps,
Switch,
SwitchProps,
ButtonProps,
} from '@chakra-ui/react';
import { SaveButton } from '@educt/components/Buttons';
/**
* Hooks
*/
import { useColorMode } from '@chakra-ui/react';
import { useSettingsContext } from '../context';
import { useHistory } from 'react-router-dom';
const InputWithButton: React.FC<
InputProps & { buttonText?: string | undefined; buttonHandler?: () => void }
> = props => {
const { buttonText, buttonHandler, ...inputProps } = props;
return (
<InputGroup size='md'>
<Input {...inputProps} />
<InputRightElement width='4.5rem' pr='0.5rem'>
<Button onClick={buttonHandler} h='1.75rem' size='sm'>
{buttonText || 'Click'}
</Button>
</InputRightElement>
</InputGroup>
);
};
export const FullnameInput: React.FC<InputProps> = props => (
<FormControl isReadOnly>
<FormLabel>Fullname</FormLabel>
<Input {...props} />
</FormControl>
);
export const EmailInput: React.FC<InputProps> = props => {
const history = useHistory();
const onChangeEmail = () => history.push(`/profile/change-email`);
return (
<FormControl isReadOnly>
<FormLabel>Email</FormLabel>
<InputWithButton buttonHandler={onChangeEmail} buttonText='Change' {...props} />
</FormControl>
);
};
export const AboutInput: React.FC<TextareaProps> = props => {
const {
form: {
register,
formState: { errors },
},
} = useSettingsContext();
return (
<FormControl>
<FormLabel>About</FormLabel>
<Textarea minH='130px' maxH='500px' isInvalid={!!errors.about} {...register('about')} {...props} />
<Text as='small' color='red.500'>
{errors.about?.message}
</Text>
<FormHelperText>Write description for your profile.</FormHelperText>
</FormControl>
);
};
export const PhoneInput: React.FC<InputProps> = props => {
const {
form: {
register,
formState: { errors },
},
} = useSettingsContext();
return (
<FormControl>
<FormLabel>Phone</FormLabel>
<Input
placeholder='+1 (234) 555-1234'
isInvalid={!!errors.phone_number}
{...register('phone_number')}
{...props}
/>
<Text as='small' color='red.500'>
{errors.phone_number?.message}
</Text>
</FormControl>
);
};
export const TwitterInput: React.FC<InputProps> = props => {
const {
form: {
register,
formState: { errors },
},
} = useSettingsContext();
return (
<FormControl>
<FormLabel>Twitter</FormLabel>
<Input placeholder='@username' isInvalid={!!errors.twitter_id} {...register('twitter_id')} {...props} />
<Text as='small' color='red.500'>
{errors.twitter_id?.message}
</Text>
</FormControl>
);
};
export const TelegramInput: React.FC<InputProps> = props => {
const {
form: {
register,
formState: { errors },
},
} = useSettingsContext();
return (
<FormControl>
<FormLabel>Telegram</FormLabel>
<Input placeholder='@username' isInvalid={!!errors.telegram_id} {...register('telegram_id')} {...props} />
<Text as='small' color='red.500'>
{errors.telegram_id?.message}
</Text>
</FormControl>
);
};
export const PasswordInput: React.FC<InputProps> = props => {
const history = useHistory();
const onChangePassword = () => history.push(`/profile/change-password`);
return (
<FormControl isReadOnly>
<FormLabel>Password</FormLabel>
<InputWithButton buttonHandler={onChangePassword} buttonText='Change' {...props} />
</FormControl>
);
};
export const LanguageSelect: React.FC<SelectProps> = props => {
return (
<FormControl>
<FormLabel>Display Language</FormLabel>
<Select {...props}>
<option value='en'>English</option>
</Select>
</FormControl>
);
};
export const ThemeSwitcher: React.FC<SwitchProps> = props => {
const { colorMode, toggleColorMode } = useColorMode();
return (
<Box>
<Switch onChange={toggleColorMode} size='lg' isChecked={colorMode === 'dark'} {...props} />
</Box>
);
};
export const SaveSettingsButton: React.FC<ButtonProps> = props => {
const {
form: {
formState: { isDirty },
},
isLoading,
} = useSettingsContext();
return (
<Box>
<SaveButton type='submit' isLoading={isLoading} isDisabled={!isDirty} {...props} />
</Box>
);
};

View File

@ -0,0 +1,82 @@
import React from 'react';
import {
Box,
BoxProps,
Heading as ChakraHeading,
Divider,
InputProps,
TextareaProps,
Stack,
StackDivider,
SelectProps,
ButtonProps,
} from '@chakra-ui/react';
import { Title, ITitleProps } from './Title';
import { Entry } from './Entry';
import { EntryContent } from './EntryContent';
import { SettingsContextProvider } from './SettingsContextProvider';
/**
* Form input fields
*/
import {
FullnameInput,
EmailInput,
AboutInput,
PhoneInput,
TwitterInput,
TelegramInput,
PasswordInput,
LanguageSelect,
ThemeSwitcher,
SaveSettingsButton,
} from './Fields';
import { SwitchProps } from 'react-router-dom';
export interface ISettingsComposition {
Title: React.FC<ITitleProps & BoxProps>;
Entry: React.FC;
EntryContent: React.FC;
FullnameInput: React.FC<InputProps>;
EmailInput: React.FC<InputProps>;
AboutInput: React.FC<TextareaProps>;
PhoneInput: React.FC<InputProps>;
TwitterInput: React.FC<InputProps>;
TelegramInput: React.FC<InputProps>;
PasswordInput: React.FC<InputProps>;
LanguageSelect: React.FC<SelectProps>;
ThemeSwitcher: React.FC<SwitchProps>;
SaveSettingsButton: React.FC<ButtonProps>;
}
const Settings: React.FC<BoxProps> & ISettingsComposition = props => {
return (
<SettingsContextProvider>
<Box maxW={{ base: 'full', lg: '750px' }} {...props}>
<ChakraHeading as='h2' mt='12' fontSize={'2xl'} fontWeight={'bold'}>
Account Settings
</ChakraHeading>
<Divider my='5' />
<Stack divider={<StackDivider />} spacing={'5'}>
{props.children}
</Stack>
</Box>
</SettingsContextProvider>
);
};
Settings.Title = Title;
Settings.Entry = Entry;
Settings.EntryContent = EntryContent;
Settings.FullnameInput = FullnameInput;
Settings.EmailInput = EmailInput;
Settings.AboutInput = AboutInput;
Settings.PhoneInput = PhoneInput;
Settings.TwitterInput = TwitterInput;
Settings.TelegramInput = TelegramInput;
Settings.PasswordInput = PasswordInput;
Settings.LanguageSelect = LanguageSelect;
Settings.ThemeSwitcher = ThemeSwitcher;
Settings.SaveSettingsButton = SaveSettingsButton;
export { Settings };

View File

@ -1,6 +1,11 @@
import yup from '@educt/schema';
const UpdateUserContactsSchema = yup.object().shape({
const SettingsSchema = yup.object().shape({
about: yup
.string()
.max(250, 'Description must be at most 250 characters')
.transform(val => (!val ? null : val))
.nullable(true),
phone_number: yup
.string()
.isMobileValid(['en-US', 'ru-RU'], { strictMode: true })
@ -16,11 +21,6 @@ const UpdateUserContactsSchema = yup.object().shape({
.matches(/(^|[^@\w])@(\w{1,64})\b/, 'Telegram username is not valid')
.transform(val => (!val ? null : val))
.nullable(true),
vk_id: yup
.string()
.matches(/^([a-zA-Z0-9_]){1,64}$/, 'VKontakte username is not valid')
.transform(val => (!val ? null : val))
.nullable(true),
});
export default UpdateUserContactsSchema;
export { SettingsSchema };

View File

@ -0,0 +1,71 @@
import React from 'react';
import { runInAction } from 'mobx';
import * as helpers from '@educt/helpers';
import { yupResolver } from '@hookform/resolvers/yup';
import { FormTypes, SettingsContext } from './context';
/**
* Hooks
*/
import { useForm } from 'react-hook-form';
import { useRootStore } from '@educt/hooks/useRootStore';
import { useUpdateUserInfo } from '@educt/hooks/queries';
/**
* Validator
*/
import { SettingsSchema } from './Settings.validator';
const SettingsContextProvider: React.FC = ({ children }) => {
const {
userStore: { me },
} = useRootStore();
const form = useForm<FormTypes>({
resolver: yupResolver(SettingsSchema),
defaultValues: {
about: me?.about || '',
phone_number: me?.contacts?.phone_number || '',
twitter_id: me?.contacts?.twitter_id || '',
telegram_id: me?.contacts?.telegram_id || '',
},
});
const { updateInfo, isLoading } = useUpdateUserInfo();
const {
formState: { dirtyFields },
reset,
} = form;
const onSubmit = async (data: FormTypes) => {
try {
const updatedFields = helpers.getDirtyFields(dirtyFields, data);
const { about, phone_number, telegram_id, twitter_id } = await updateInfo(updatedFields);
/**
* Update info in store
*/
runInAction(() => {
if (me !== null) {
me.about = about === undefined ? me.about : about;
if (me.contacts) {
me.contacts.phone_number = phone_number === undefined ? me.contacts.phone_number : phone_number;
me.contacts.twitter_id = twitter_id === undefined ? me.contacts.twitter_id : twitter_id;
me.contacts.telegram_id = telegram_id === undefined ? me.contacts.telegram_id : telegram_id;
}
}
});
reset(data);
} catch (error) {
console.error(error);
}
};
return (
<SettingsContext.Provider value={{ form, isLoading }}>
<form onSubmit={form.handleSubmit(onSubmit)}>{children}</form>
</SettingsContext.Provider>
);
};
export { SettingsContextProvider };

View File

@ -0,0 +1,18 @@
import React from 'react';
import { Box, BoxProps, Heading } from '@chakra-ui/react';
export interface ITitleProps {
title?: string | undefined;
}
const Title: React.FC<ITitleProps & BoxProps> = props => {
return (
<Box minW='3xs' {...props}>
<Heading as='h2' fontWeight={'semibold'} fontSize={'lg'}>
{props.title}
</Heading>
</Box>
);
};
export { Title };

View File

@ -0,0 +1,26 @@
import { createContext, useContext } from 'react';
import { UseFormReturn } from 'react-hook-form';
interface ISettingsContext {
form: UseFormReturn<FormTypes, object>;
isLoading: boolean;
}
export type FormTypes = {
about: string;
phone_number: string;
twitter_id: string;
telegram_id: string;
};
export const SettingsContext = createContext<ISettingsContext | undefined>(undefined);
export const useSettingsContext = () => {
const context = useContext(SettingsContext);
if (!context) {
throw new Error('SettingsContext was outside of its provider');
}
return context;
};

View File

@ -0,0 +1 @@
export { Settings } from './Settings';

View File

@ -0,0 +1,37 @@
import React from 'react';
import { BoxProps, Flex, FlexProps } from '@chakra-ui/react';
import { Base } from './Base';
import { Avatar, ChatButton, IAvatarProps, IChatButtonProps } from './Avatar';
import { About, Details, Heading, IAboutProps, IDetailsProps, IHeadingProps, Info } from './Info';
import { Settings } from './Settings';
import { ISettingsComposition } from './Settings/Settings';
interface IUserProfileComposition {
Base: React.FC<FlexProps>;
Avatar: React.FC<IAvatarProps>;
ChatButton: React.FC<IChatButtonProps>;
Info: React.FC<BoxProps>;
Heading: React.FC<IHeadingProps>;
Details: React.FC<IDetailsProps>;
About: React.FC<IAboutProps>;
Settings: React.FC<BoxProps> & ISettingsComposition;
}
const UserProfile: React.FC<FlexProps> & IUserProfileComposition = props => {
return (
<Flex flexDir={{ base: 'column' }} {...props}>
{props.children}
</Flex>
);
};
UserProfile.Base = Base;
UserProfile.Avatar = Avatar;
UserProfile.ChatButton = ChatButton;
UserProfile.Info = Info;
UserProfile.Heading = Heading;
UserProfile.Details = Details;
UserProfile.About = About;
UserProfile.Settings = Settings;
export { UserProfile };

View File

@ -29,6 +29,7 @@ import { useCreateUser } from './user/useCreateUser';
import { useDeleteUser } from './user/useDeleteUser';
import { useUpdateUser } from './user/useUpdateUser';
import { useUpdateUserEmail } from './user/useUpdateUserEmail';
import { useUpdateUserInfo } from './user/useUpdateUserInfo';
/**
* Category
@ -59,6 +60,7 @@ export {
useCreateUser,
useDeleteUser,
useUpdateUser,
useUpdateUserInfo,
useUpdateUserEmail,
useCreateCategory,
useDeleteCategory,

View File

@ -0,0 +1,25 @@
import { useEffect } from 'react';
import useAsync from '@educt/hooks/useAsync';
import { IUser } from '@educt/interfaces';
import { UserServiceInstance } from '@educt/services';
const useFetchUser = (id: string) => {
const fetch = async (id: string) => {
try {
const { data } = await UserServiceInstance.fetchUserById(id);
return data;
} catch (error) {
return Promise.reject(error);
}
};
const { execute: fetchUserById, ...state } = useAsync<IUser, Parameters<typeof fetch>>(fetch);
useEffect(() => {
fetchUserById(id);
}, []);
return { ...state };
};
export { useFetchUser };

View File

@ -0,0 +1,42 @@
import { useToast } from '@chakra-ui/react';
import useAsync from '@educt/hooks/useAsync';
import { IUserInfo } from '@educt/interfaces';
import { UserServiceInstance } from '@educt/services';
import { UpdateUserInfoParamsType } from '@educt/types';
import axios from 'axios';
import { useErrorHandler } from 'react-error-boundary';
const useUpdateUserInfo = () => {
const toast = useToast();
const handleError = useErrorHandler();
const fetch = async (data: UpdateUserInfoParamsType) => {
try {
const result = await UserServiceInstance.updateInfo(data);
toast({ title: 'Info updated.', status: 'info' });
return result.data;
} catch (error) {
if (axios.isAxiosError(error)) {
if (error.response) {
switch (error.response.status) {
case 422:
toast({ title: error.response.data.errors[0].message, status: 'error', duration: 2000 });
break;
default:
handleError(error);
}
} else {
handleError(error);
}
}
return Promise.reject(error);
}
};
const { execute, ...state } = useAsync<Partial<IUserInfo>, Parameters<typeof fetch>>(fetch);
return { updateInfo: execute, ...state };
};
export { useUpdateUserInfo };

View File

@ -116,6 +116,8 @@ export interface IUser {
first_name: string;
last_name: string;
fullname: string;
about: string | null;
last_login: string | null;
email: string;
roles: IUserRole[];
contacts: IUserContacts | null;
@ -145,6 +147,10 @@ export interface IUserContacts {
vk_id: string | null;
}
export interface IUserInfo extends IUserContacts {
about: string | null;
}
export interface IUserRole {
id: number;
name: string;

View File

@ -8,7 +8,7 @@ import { MdError } from 'react-icons/md';
* Types
*/
import { IPageProps } from '@educt/interfaces';
import { PageContent, PageWrapper } from '@educt/components/PageElements';
import { Page } from '@educt/components/PageElements';
/**
* Hooks
@ -28,8 +28,8 @@ const NotFoundPage: React.FC<IPageProps> = ({ title }) => {
<Helmet>
<title>{title}</title>
</Helmet>
<PageWrapper textAlign='center' mt={40}>
<PageContent>
<Page textAlign='center' mt={40}>
<Page.Content>
<Icon as={MdError} w='16' h='16' />
<Heading display='block' as='h1' fontSize='6xl'>
Page not found
@ -38,8 +38,8 @@ const NotFoundPage: React.FC<IPageProps> = ({ title }) => {
<Link as={ReactRouterLink} to='/' color='blue.500'>
Return to main page
</Link>
</PageContent>
</PageWrapper>
</Page.Content>
</Page>
</>
);
};

View File

@ -1,10 +1,14 @@
import React from 'react';
import { IPageProps } from '@educt/interfaces';
import { PageContent, PageHeading, PageWrapper } from '@educt/components/PageElements';
import { Page } from '@educt/components/PageElements';
import { CategoryTableList, CategoryTableRow } from '@educt/components/Tables/CategoryTableList';
import { useFetchCategories } from '@educt/hooks/queries';
import LoadingPage from '@educt/components/LoadingPage';
/**
* Hooks
*/
import { useFetchCategories } from '@educt/hooks/queries';
/**
* Categories page
*/
@ -14,12 +18,12 @@ const CategoriesPage: React.FC<IPageProps> = () => {
if (isLoading || categories === null) return <LoadingPage />;
return (
<PageWrapper>
<PageHeading heading='Categories' description='Edit categories in the system.' />
<PageContent>
<Page>
<Page.Heading heading='Categories' description='Edit categories in the system.' />
<Page.Content>
<CategoryTableList categories={categories} render={CategoryTableRow} />
</PageContent>
</PageWrapper>
</Page.Content>
</Page>
);
};

View File

@ -15,7 +15,7 @@ import type { IPageProps } from '@educt/interfaces';
* Components
*/
import { CourseBackgroundImage, CourseHeader, CourseHeading, CourseInfo, LessonItem, LessonList } from '.';
import { PageWrapper, PageContent } from '@educt/components/PageElements';
import { Page } from '@educt/components/PageElements';
import LoadingPage from '@educt/components/LoadingPage';
/**
@ -50,7 +50,7 @@ const CoursePage: React.FC<IPageProps> = () => {
const handleEditCourse = () => history.push(`/courses/edit/${course.id}`);
return (
<PageWrapper maxW='1200px'>
<Page maxW='1200px'>
<Helmet>
<title>{course.title}</title>
</Helmet>
@ -64,7 +64,7 @@ const CoursePage: React.FC<IPageProps> = () => {
/>
</CourseHeader>
<PageContent mt={{ base: '5', sm: '2' }}>
<Page.Content mt={{ base: '5', sm: '2' }}>
<Grid templateColumns={{ base: '1fr', xl: '3fr 1fr' }} gap={'7'}>
<GridItem>
<Box>
@ -158,8 +158,8 @@ const CoursePage: React.FC<IPageProps> = () => {
</Box>
</GridItem>
</Grid>
</PageContent>
</PageWrapper>
</Page.Content>
</Page>
);
};

View File

@ -8,7 +8,7 @@ import { IPageProps } from '@educt/interfaces';
/**
* Components
*/
import { PageWrapper, PageHeading, PageContent } from '@educt/components/PageElements';
import { Page } from '@educt/components/PageElements';
import { CreateButton } from '@educt/components/Buttons';
import { Box } from '@chakra-ui/layout';
import LoadingPage from '@educt/components/LoadingPage';
@ -38,18 +38,18 @@ const CoursesPage: React.FC<IPageProps> = () => {
if (me === null) return <LoadingPage />;
return (
<PageWrapper>
<PageHeading heading='Courses' description='List of all courses'>
<Page>
<Page.Heading heading='Courses' description='List of all courses'>
<Box mt='5'>
{(me.isAdmin || me.isTeacher) && <CreateButton onClick={handleCreateCourse} ml='auto' display='block' />}
</Box>
</PageHeading>
<PageContent mt='10'>
</Page.Heading>
<Page.Content mt='10'>
<CoursesTabs mb='7' />
<CategoryList render={CategoryItem} />
<CourseList render={CourseItem} />
</PageContent>
</PageWrapper>
</Page.Content>
</Page>
);
};

View File

@ -1,7 +1,7 @@
import React from 'react';
import { IPageProps } from '@educt/interfaces';
import { PageContent, PageHeading, PageWrapper } from '@educt/components/PageElements';
import { Page } from '@educt/components/PageElements';
import CreateLessonForm from '@educt/components/Forms/LessonForm/CreateLessonForm';
import { Redirect, useParams } from 'react-router-dom';
import { useFetchCourse } from '@educt/hooks/queries';
@ -21,12 +21,12 @@ const CreateLessonPage: React.FC<IPageProps> = () => {
if (!course || isLoading) return <LoadingPage />;
return (
<PageWrapper>
<PageHeading heading='Lesson Creation' description='Create new lesson' />
<PageContent>
<Page>
<Page.Heading heading='Lesson Creation' description='Create new lesson' />
<Page.Content>
<CreateLessonForm />
</PageContent>
</PageWrapper>
</Page.Content>
</Page>
);
};

View File

@ -1,5 +1,4 @@
import React from 'react';
import { Flex, Box, Heading, Text } from '@chakra-ui/react';
/**
* Types
@ -11,23 +10,23 @@ import { IPageProps } from '@educt/interfaces';
*/
import PrevPageButton from '@educt/components/PrevPageButton';
import { CreateCourseForm } from '@educt/components/Forms/CourseForm';
import { PageContent, PageHeading, PageWrapper } from '@educt/components/PageElements';
import { Page } from '@educt/components/PageElements';
/**
* Create course page
*/
const CreateCoursePage: React.FC<IPageProps> = () => {
return (
<PageWrapper>
<PageHeading
<Page>
<Page.Heading
headingPrefix={<PrevPageButton prevPage='/courses' />}
heading='Course Creation'
description='Fill in the required fields to create a new course'
/>
<PageContent>
<Page.Content>
<CreateCourseForm />
</PageContent>
</PageWrapper>
</Page.Content>
</Page>
);
};

View File

@ -17,7 +17,7 @@ import DeleteCourseDialog from '@educt/components/Dialogs/DeleteCourseDialog';
import PrevPageButton from '@educt/components/PrevPageButton';
import { DeleteButton } from '@educt/components/Buttons';
import { StatusButton } from './components';
import { PageContent, PageHeading, PageWrapper } from '@educt/components/PageElements';
import { Page } from '@educt/components/PageElements';
import { StudentTableList, StudentTableRow } from './components/StudentTableList';
import LessonList from './components/LessonList';
@ -43,13 +43,13 @@ const EditCoursePage: React.FC<IPageProps> = () => {
if (!course || isLoading) return <LoadingPage />;
return (
<PageWrapper>
<Page>
<Helmet>
<title>Editing {course.title}</title>
</Helmet>
<DeleteCourseDialog course={course} onClose={onCloseDeleteDialog} isOpen={isOpenDeleteDialog} />
<PageHeading
<Page.Heading
headingPrefix={<PrevPageButton prevPage='/courses' />}
heading='Course editor'
description={course.title}
@ -58,8 +58,8 @@ const EditCoursePage: React.FC<IPageProps> = () => {
<StatusButton courseId={course.id} currentStatus={course.status} />
<DeleteButton onClick={onOpenDeleteDialog} ml='2' />
</Flex>
</PageHeading>
<PageContent>
</Page.Heading>
<Page.Content>
<Box>
<Tabs mt='8'>
<TabList>
@ -91,8 +91,8 @@ const EditCoursePage: React.FC<IPageProps> = () => {
</TabPanels>
</Tabs>
</Box>
</PageContent>
</PageWrapper>
</Page.Content>
</Page>
);
};

View File

@ -22,7 +22,7 @@ import {
import ReactPlayer from 'react-player';
import { MdOutlineCircle, MdCheckCircle, MdOutlineFilePresent, MdSkipNext, MdSkipPrevious } from 'react-icons/md';
import LoadingPage from '@educt/components/LoadingPage';
import { PageContent, PageHeading, PageWrapper } from '@educt/components/PageElements';
import { Page } from '@educt/components/PageElements';
/**
* Types
@ -138,17 +138,17 @@ const LessonPage: React.FC<IPageProps> = () => {
if (me === null) return <LoadingPage />;
return (
<PageWrapper>
<Page>
<Helmet>
<title>{lesson.title}</title>
</Helmet>
<PageHeading heading={lesson.title}>
<Page.Heading heading={lesson.title}>
<Flex mt='3' alignItems={'center'} justifyContent={'space-between'}>
<Text fontSize={'sm'}>~{moment.duration(lesson.duration, 'minutes').humanize()}</Text>
{(me.isAdmin || me.isTeacher) && <EditButton onClick={handleEditLesson} />}
</Flex>
</PageHeading>
<PageContent>
</Page.Heading>
<Page.Content>
<Grid templateColumns={{ base: '1fr', xl: '3fr 1fr' }}>
<GridItem>
<Box>
@ -270,8 +270,8 @@ const LessonPage: React.FC<IPageProps> = () => {
</Box>
</GridItem>
</Grid>
</PageContent>
</PageWrapper>
</Page.Content>
</Page>
);
};

View File

@ -1,10 +1,10 @@
import React from 'react';
import { IPageProps } from '@educt/interfaces';
import { PageContent, PageHeading, PageWrapper } from '@educt/components/PageElements';
import { Page } from '@educt/components/PageElements';
import { Redirect, useParams } from 'react-router-dom';
import LoadingPage from '@educt/components/LoadingPage';
import { useFetchLesson } from '@educt/hooks/queries/lesson/useFetchLesson';
import EditLessonForm from '@educt/components/Forms/LessonForm/EditLessonForm';
import { useFetchLesson } from '@educt/hooks/queries/lesson/useFetchLesson';
/**
* Create Lesson page
@ -20,12 +20,12 @@ const EditLessonPage: React.FC<IPageProps> = () => {
if (!lesson || isLoading) return <LoadingPage />;
return (
<PageWrapper>
<PageHeading heading='Lesson Editor' description={lesson.description} />
<PageContent>
<Page>
<Page.Heading heading='Lesson Editor' description={lesson.description} />
<Page.Content>
<EditLessonForm lesson={lesson} />
</PageContent>
</PageWrapper>
</Page.Content>
</Page>
);
};

View File

@ -34,7 +34,7 @@ export const CourseCard: React.FC<BoxProps> = props => <Card py='5' px='6' {...p
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 CourseCardTitle: React.FC<TextProps> = props => <Text fontSize={'2xl'} fontWeight={'medium'} {...props} />;
export const CourseCardCategory: React.FC = props => (
<Flex mt='2'>
<Tag size='sm' borderRadius='full' variant={'solid'}>

View File

@ -1,8 +1,8 @@
import React, { useState } from 'react';
import React 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 { Page } 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';
@ -21,9 +21,13 @@ import {
CourseCardList,
} from './components';
import { observer } from 'mobx-react';
/**
* Hooks
*/
import { useEffect, useState } from 'react';
import { useFetchCourses, useFetchStat } from '@educt/hooks/queries';
import { useSocketEvent } from '@educt/hooks/useSocketEvent';
import { useEffect } from 'react';
/**
* Main page
@ -52,9 +56,9 @@ const MainPage: React.FC<IPageProps> = () => {
if (me === null || courses === null || stat === null) return <LoadingPage />;
return (
<PageWrapper>
<PageHeading heading={`👋 Hello ${me.first_name},`} description="This what we've got today for you." />
<PageContent>
<Page>
<Page.Heading heading={`👋 Hello ${me.first_name},`} description="This what we've got today for you." />
<Page.Content>
<SimpleGrid columns={{ base: 1, md: 2, lg: 4 }} spacing='6'>
<Stat>
<StatLabel>Online</StatLabel>
@ -121,8 +125,8 @@ const MainPage: React.FC<IPageProps> = () => {
</CourseCardList>
</Box>
<Divider />
</PageContent>
</PageWrapper>
</Page.Content>
</Page>
);
};

View File

@ -1,5 +1,6 @@
import React from 'react';
import { Box, Heading, Breadcrumb, BreadcrumbItem, BreadcrumbLink, Grid } from '@chakra-ui/react';
import { Box, Heading } from '@chakra-ui/react';
import { Page } from '@educt/components/PageElements';
import { IPageProps } from '@educt/interfaces';
@ -8,9 +9,9 @@ import { IPageProps } from '@educt/interfaces';
*/
const MessagesPage: React.FC<IPageProps> = () => {
return (
<Box>
<Heading as='h1'>Messages page</Heading>
</Box>
<Page>
<Page.Heading heading='Messages' />
</Page>
);
};

View File

@ -1,165 +0,0 @@
import React from 'react';
import * as helpers from '@educt/helpers';
import { observer } from 'mobx-react';
import { SubmitHandler, useForm } from 'react-hook-form';
import { MdSave } from 'react-icons/md';
import { Text, Input } from '@chakra-ui/react';
import { Box, Stack } from '@chakra-ui/layout';
import { FormControl, FormHelperText } from '@chakra-ui/form-control';
import { yupResolver } from '@hookform/resolvers/yup';
/**
* Types
*/
import { IUserContacts } from '@educt/interfaces';
/**
* Hooks
*/
import { useState } from 'react';
import { useToast } from '@chakra-ui/toast';
import { useRootStore } from '@educt/hooks/useRootStore';
import { useErrorHandler } from 'react-error-boundary';
/**
* Schema
*/
import UpdateUserContactsSchema from './UpdateUserContactsForm.validator';
import { SaveButton } from '@educt/components/Buttons';
type UpdateUserContactsInputType = {
phone_number: string | null;
twitter_id: string | null;
telegram_id: string | null;
vk_id: string | null;
};
type UpdateUserContactsFormPropsType = {
contacts: IUserContacts | null;
};
const UpdateUserContactsForm: React.FC<UpdateUserContactsFormPropsType> = ({ contacts }) => {
const {
register,
formState: { errors, dirtyFields, isDirty },
handleSubmit,
reset,
} = useForm<UpdateUserContactsInputType>({
resolver: yupResolver(UpdateUserContactsSchema),
defaultValues: {
phone_number: contacts && (contacts.phone_number ?? ''),
twitter_id: contacts && (contacts.twitter_id?.replace(/^/, '@') ?? ''),
telegram_id: contacts && (contacts.telegram_id?.replace(/^/, '@') ?? ''),
vk_id: contacts && (contacts.vk_id ?? ''),
},
});
const [isLoading, setIsLoading] = useState<boolean>(false);
const { userStore } = useRootStore();
const toast = useToast();
const handleError = useErrorHandler();
/**
* Sumbit form handler
*/
const onSubmit: SubmitHandler<UpdateUserContactsInputType> = async data => {
try {
setIsLoading(true);
const updatedFields = helpers.getDirtyFields(dirtyFields, data);
await userStore.updateCurrentUserContacts(updatedFields);
toast({ title: 'Contacts saved!', status: 'info' });
reset({
phone_number: data.phone_number ?? '',
twitter_id: data.twitter_id ?? '',
telegram_id: data.telegram_id ?? '',
vk_id: data.vk_id ?? '',
});
} catch (error: any) {
if (error.response) {
switch (error.response.status) {
case 422:
toast({ title: error.response.data.errors[0].message, status: 'error', duration: 2000 });
break;
default:
handleError(error);
}
} else {
handleError(error);
}
} finally {
setIsLoading(false);
}
};
return (
<Box>
<form onSubmit={handleSubmit(onSubmit)}>
<Box borderRadius='lg' borderWidth='1px' padding='5'>
<Stack spacing='5px'>
<FormControl id='phone_number'>
<FormHelperText color='gray.500'>Phone number with country code</FormHelperText>
<Input
type='text'
size='md'
variant='flushed'
placeholder='+1 (234) 555-1234'
isInvalid={errors.phone_number ? true : false}
{...register('phone_number')}
/>
<Text as='small' color='red.500'>
{errors.phone_number?.message}
</Text>
</FormControl>
<FormControl id='twitter_id'>
<FormHelperText color='gray.500'>Twitter</FormHelperText>
<Input
type='text'
size='md'
variant='flushed'
placeholder='@username'
isInvalid={errors.twitter_id ? true : false}
{...register('twitter_id')}
/>
<Text as='small' color='red.500'>
{errors.twitter_id?.message}
</Text>
</FormControl>
<FormControl id='telegram'>
<FormHelperText color='gray.500'>Telegram</FormHelperText>
<Input
type='text'
size='md'
variant='flushed'
placeholder='@username'
isInvalid={errors.telegram_id ? true : false}
{...register('telegram_id')}
/>
<Text as='small' color='red.500'>
{errors.telegram_id?.message}
</Text>
</FormControl>
<FormControl id='vk'>
<FormHelperText color='gray.500'>VKontakte</FormHelperText>
<Input
type='text'
size='md'
variant='flushed'
placeholder='username'
isInvalid={errors.vk_id ? true : false}
{...register('vk_id')}
/>
<Text as='small' color='red.500'>
{errors.vk_id?.message}
</Text>
</FormControl>
</Stack>
</Box>
<SaveButton type='submit' mt='4' isLoading={isLoading} rightIcon={<MdSave />} isDisabled={!isDirty} />
</form>
</Box>
);
};
export default observer(UpdateUserContactsForm);

View File

@ -1,3 +0,0 @@
import UpdateUserContactsForm from './UpdateUserContactsForm';
export default UpdateUserContactsForm;

View File

@ -1,100 +0,0 @@
import React from 'react';
import { FlexProps, Flex, BoxProps, Box, Heading, Text } from '@chakra-ui/layout';
import { Avatar } from '@chakra-ui/avatar';
import { Button, ButtonProps } from '@chakra-ui/button';
import { Skeleton, SkeletonCircle, Spinner } from '@chakra-ui/react';
/**
* Types
*/
import { IUserRole } from '@educt/interfaces';
/**
* Components
*/
import UserBadge from '@educt/components/UserBadge';
/**
* Hooks
*/
import { useColorMode } from '@chakra-ui/react';
import { useLogout } from '@educt/hooks/queries';
export const ProfileBaseInfo: React.FC<FlexProps> = props => {
return (
<Flex flexDir={{ base: 'column', md: 'row' }} textAlign={{ base: 'center', md: 'left' }} {...props}>
{props.children}
</Flex>
);
};
interface IProfileAvatarProps extends BoxProps {
name: string;
}
export const ProfileAvatar: React.FC<IProfileAvatarProps> = props => {
const { colorMode } = useColorMode();
const isDark = colorMode === 'dark';
return (
<Box>
<Avatar border={`4px solid ${isDark ? '#2D3748' : '#E2E8F0'}`} size='2xl' name={props.name} />
</Box>
);
};
interface IProfileDescriptionProps extends BoxProps {
fullname: string;
email: string;
roles: IUserRole[];
}
export const ProfileDescription: React.FC<IProfileDescriptionProps> = props => {
const { fullname, email, roles, ...boxProps } = props;
return (
<Box w='full' ml={{ md: '50' }} mt={{ base: '2', md: '3' }} {...boxProps}>
<Flex alignItems='center' justifyContent={{ base: 'center', md: 'space-between' }}>
<Box>
<Heading lineHeight='1.5rem' fontWeight='bold' fontSize='24px'>
{fullname}
<br />
<Text as='span' fontSize='sm' fontWeight='normal'>
{email}
</Text>
</Heading>
<Box>
<UserBadge mt='2' roles={roles} />
</Box>
</Box>
</Flex>
</Box>
);
};
export const ProfileSignOutButton: React.FC<ButtonProps> = () => {
const { logout } = useLogout();
return (
<Button onClick={logout} size='sm' w={{ base: 'full', sm: '150px' }}>
Sign Out
</Button>
);
};
export const ProfileLoading: React.FC<BoxProps> = props => {
return (
<Box maxW='900px' {...props}>
<Skeleton height='35px' width='250px' borderRadius='md' />
<Flex alignItems='center' mt='20'>
<Box>
<SkeletonCircle size='128px' />
</Box>
<Flex ml='50' flexDirection='column'>
<Skeleton height='25px' width='200px' mb='5' borderRadius='md' />
<Skeleton height='25px' width='300px' borderRadius='md' />
</Flex>
</Flex>
<Flex mt='20' justifyContent='center'>
<Spinner size='xl' />
</Flex>
</Box>
);
};

View File

@ -1,37 +1,22 @@
import React from 'react';
import {
Avatar,
Tabs,
TabList,
Tab,
TabPanels,
TabPanel,
Button,
FormControl,
FormHelperText,
Input,
InputGroup,
InputRightElement,
} from '@chakra-ui/react';
import { Box, Stack } from '@chakra-ui/layout';
import { observer } from 'mobx-react';
import { Stack } from '@chakra-ui/layout';
/**
* Types
*/
import { IPageProps } from '@educt/interfaces';
import type { IPageProps } from '@educt/interfaces';
/**
* Components
*/
import { PageHeading, PageContent, PageWrapper, PageFooter } from '@educt/components/PageElements';
import { ProfileAvatar, ProfileBaseInfo, ProfileDescription, ProfileLoading, ProfileSignOutButton } from './components';
import { CourseList } from './components/CourseList';
import UpdateUserContactsForm from '@educt/pages/profile/components/UpdateUserContactsForm';
import { Page } from '@educt/components/PageElements';
import { UserProfile } from '@educt/components/UserProfile';
import LoadingPage from '@educt/components/LoadingPage';
/**
* Hooks
*/
import { useHistory } from 'react-router-dom';
import { useRootStore } from '@educt/hooks/useRootStore';
/**
@ -41,87 +26,84 @@ const ProfilePage: React.FC<IPageProps> = () => {
const {
userStore: { me },
} = useRootStore();
const history = useHistory();
if (me === null) return <ProfileLoading />;
if (me === null) return <LoadingPage />;
return (
<PageWrapper maxW='900px'>
<PageHeading heading='My account' description='Update you contacts information or password here.' />
<ProfileBaseInfo mt='20'>
<ProfileAvatar name={me.fullname} />
<ProfileDescription fullname={me.fullname} email={me.email} roles={me.roles} />
</ProfileBaseInfo>
<PageContent>
<Tabs mt='7'>
<TabList>
<Tab>Account information</Tab>
<Tab>My contacts</Tab>
{me.isStudent && <Tab>Available courses</Tab>}
</TabList>
<TabPanels mt='2'>
<TabPanel>
<form>
<Box borderRadius='md' borderWidth='1px' padding='5'>
<Stack spacing='5px'>
<FormControl isDisabled>
<FormHelperText color='gray.500'>First name</FormHelperText>
<Input type='text' value={me.first_name} size='md' variant='flushed' />
</FormControl>
<Page maxW={'900px'}>
<Page.Heading heading='My account' description='Update your profile information or password here.' />
<Page.Content>
<UserProfile mt='14'>
<UserProfile.Base>
<UserProfile.Avatar name={me.fullname} />
<UserProfile.Info>
<UserProfile.Heading fullname={me.fullname} roles={me.roles} />
<UserProfile.Details registered={me.created_at} lastLogin={me.last_login} />
<UserProfile.About about={me.about} />
</UserProfile.Info>
</UserProfile.Base>
<FormControl isDisabled>
<FormHelperText color='gray.500'>Last name</FormHelperText>
<Input type='text' value={me.last_name} size='md' variant='flushed' />
</FormControl>
<UserProfile.Settings>
{/* Personal Info */}
<UserProfile.Settings.Entry>
<UserProfile.Settings.Title title={'Personal Info'} />
<UserProfile.Settings.EntryContent>
<Stack spacing={'6'}>
<UserProfile.Settings.FullnameInput defaultValue={me.fullname} />
<UserProfile.Settings.EmailInput defaultValue={me.email} />
<UserProfile.Settings.AboutInput />
</Stack>
</UserProfile.Settings.EntryContent>
</UserProfile.Settings.Entry>
<FormControl isDisabled>
<FormHelperText color='gray.500'>Email</FormHelperText>
<InputGroup>
<Input type='text' value={me.email} size='md' variant='flushed' />
<InputRightElement width='4rem'>
<Button size='sm' h='1.90rem' w='full' onClick={() => history.push('/profile/change-email')}>
Edit
</Button>
</InputRightElement>
</InputGroup>
</FormControl>
{/* Contacts */}
<UserProfile.Settings.Entry>
<UserProfile.Settings.Title title={'Contacts'} />
<UserProfile.Settings.EntryContent>
<Stack spacing={'6'}>
<UserProfile.Settings.PhoneInput />
<UserProfile.Settings.TwitterInput />
<UserProfile.Settings.TelegramInput />
</Stack>
</UserProfile.Settings.EntryContent>
</UserProfile.Settings.Entry>
<FormControl isDisabled>
<FormHelperText color='gray.500'>Password</FormHelperText>
<InputGroup>
<Input type='text' value='**********' size='md' variant='flushed' />
<InputRightElement width='5rem'>
<Button
size='sm'
h='1.90rem'
w='full'
onClick={() => history.push('/profile/change-password')}
>
Change
</Button>
</InputRightElement>
</InputGroup>
</FormControl>
</Stack>
</Box>
</form>
</TabPanel>
<TabPanel>
<UpdateUserContactsForm contacts={me.contacts} />
</TabPanel>
{me.isStudent && (
<TabPanel>
<CourseList courses={me.courses} />
</TabPanel>
)}
</TabPanels>
</Tabs>
</PageContent>
<PageFooter textAlign='center'>
<ProfileSignOutButton />
</PageFooter>
</PageWrapper>
{/* Security */}
<UserProfile.Settings.Entry>
<UserProfile.Settings.Title title='Security' />
<UserProfile.Settings.EntryContent>
<UserProfile.Settings.PasswordInput defaultValue={'**********'} />
</UserProfile.Settings.EntryContent>
</UserProfile.Settings.Entry>
{/* Language */}
<UserProfile.Settings.Entry>
<UserProfile.Settings.Title title='Language' />
<UserProfile.Settings.EntryContent>
<UserProfile.Settings.LanguageSelect />
</UserProfile.Settings.EntryContent>
</UserProfile.Settings.Entry>
{/* Night Theme */}
<UserProfile.Settings.Entry>
<UserProfile.Settings.Title title='Night Theme' />
<UserProfile.Settings.EntryContent>
<UserProfile.Settings.ThemeSwitcher />
</UserProfile.Settings.EntryContent>
</UserProfile.Settings.Entry>
{/* Save Button */}
<UserProfile.Settings.Entry>
<UserProfile.Settings.Title />
<UserProfile.Settings.EntryContent>
<UserProfile.Settings.SaveSettingsButton />
</UserProfile.Settings.EntryContent>
</UserProfile.Settings.Entry>
</UserProfile.Settings>
</UserProfile>
</Page.Content>
</Page>
);
};
export default ProfilePage;
export default observer(ProfilePage);

52
src/pages/user/user.tsx Normal file
View File

@ -0,0 +1,52 @@
import React from 'react';
import { observer } from 'mobx-react';
import { Redirect } from 'react-router-dom';
import LoadingPage from '@educt/components/LoadingPage';
import { Page } from '@educt/components/PageElements';
import { UserProfile } from '@educt/components/UserProfile';
import type { IPageProps } from '@educt/interfaces';
/**
* Hooks
*/
import { useParams } from 'react-router-dom';
import { useFetchUser } from '@educt/hooks/queries/user/useFetchUser';
import { useRootStore } from '@educt/hooks/useRootStore';
const UserPage: React.FC<IPageProps> = () => {
const params = useParams<{ id: string }>();
const {
userStore: { me },
} = useRootStore();
const { data: user, error, isLoading } = useFetchUser(params.id);
if (error?.response?.status === 404) return <Redirect to='/404' />;
if (user === null || me === null || isLoading) return <LoadingPage />;
/**
* Redirect to profile page
*/
if (me.id === params.id) return <Redirect to='/profile' />;
return (
<Page>
<Page.Content maxW={'900px'}>
<UserProfile>
<UserProfile.Base>
<UserProfile.Avatar name={user.fullname} isOnline>
<UserProfile.ChatButton userid={user.id} />
</UserProfile.Avatar>
<UserProfile.Info>
<UserProfile.Heading fullname={user.fullname} roles={user.roles} />
<UserProfile.Details registered={user.created_at} lastLogin={user.last_login} />
<UserProfile.About about={user.about} />
</UserProfile.Info>
</UserProfile.Base>
</UserProfile>
</Page.Content>
</Page>
);
};
export default observer(UserPage);

View File

@ -8,7 +8,7 @@ import type { IPageProps } from '@educt/interfaces';
/**
* Components
*/
import { PageContent, PageHeading, PageWrapper } from '@educt/components/PageElements';
import { Page } from '@educt/components/PageElements';
import { UserList, UserItem } from './components/UserList';
import LoadingPage from '@educt/components/LoadingPage';
@ -35,12 +35,12 @@ const UsersPage: React.FC<IPageProps> = () => {
return (
// TODO: delete page context for better performance
<UsersPageContextProvider>
<PageWrapper>
<PageHeading heading='User management' description='You can add or delete users on this page.' />
<PageContent mt='5'>
<Page>
<Page.Heading heading='User management' description='You can add or delete users on this page.' />
<Page.Content mt='5'>
<UserList render={UserItem} limit={6} />
</PageContent>
</PageWrapper>
</Page.Content>
</Page>
</UsersPageContextProvider>
);
};

View File

@ -14,6 +14,7 @@ import CoursePage from '@educt/pages/course/course';
import EditCoursePage from '@educt/pages/courses/edit/edit';
import ChangePasswordPage from '@educt/pages/profile/change-password/change-password';
import ChangeEmailPage from '@educt/pages/profile/change-email/change-email';
import UserPage from './pages/user/user';
import { UserRoleEnum } from './enums';
const CoursesPage = lazy(() => import('@educt/pages/courses/courses'));
const LessonPage = lazy(() => import('@educt/pages/lesson/lesson'));
@ -126,6 +127,13 @@ export const routes: IRouteOption[] = [
isPrivate: true,
roles: [UserRoleEnum.ADMIN, UserRoleEnum.TEACHER],
},
{
path: '/user/:id',
title: 'User',
component: UserPage,
isPrivate: true,
exact: true,
},
{
path: '/auth',
title: 'Auth',

View File

@ -1,12 +1,13 @@
import { AxiosInstance } from 'axios';
import { ApiServiceInstance } from '.';
import * as helpers from '@educt/helpers';
import { IApiRespose, IMe, IUser, IUserContacts } from '@educt/interfaces';
import { IApiRespose, IMe, IUser, IUserContacts, IUserInfo } from '@educt/interfaces';
import {
CreateUserParamsType,
FetchUsersParamsType,
UpdateUserParamsType,
UpdateUserContactsParamsType,
UpdateUserInfoParamsType,
} from '@educt/types';
class UserService {
@ -45,6 +46,17 @@ class UserService {
return result.data;
}
/**
* Fetch user by id
*
* @param id User id
* @returns Users
*/
public async fetchUserById(id: string): Promise<IApiRespose<IUser>> {
const result = await this.api.get(`/v1/users/${id}`);
return result.data;
}
/**
* Create new user
*
@ -97,6 +109,17 @@ class UserService {
return result.data;
}
/**
* Update user info
*
* @param data Fields to update
* @returns Updated fields
*/
public async updateInfo(data: UpdateUserInfoParamsType): Promise<IApiRespose<Partial<IUserInfo>>> {
const result = await this.api.patch('v1/me/info', data);
return result.data;
}
/**
* Update password of authorized user
*

View File

@ -8,6 +8,7 @@ import {
CreateUserParamsType,
FetchUsersParamsType,
UpdateUserContactsParamsType,
UpdateUserInfoParamsType,
UpdateUserParamsType,
} from '@educt/types';
import { IUser, IMe, IPaginationMeta } from '@educt/interfaces';
@ -62,10 +63,25 @@ export default class UserStore {
public async loadCurrentUserData() {
const result = await UserServiceInstance.fetchMe();
const {
data: { id, first_name, last_name, fullname, email, roles, likes, contacts, courses, created_at, updated_at },
data: {
id,
first_name,
last_name,
fullname,
about,
last_login,
email,
roles,
likes,
contacts,
courses,
created_at,
updated_at,
},
} = result;
runInAction(() => {
// TODO Fix this
this.me = {
id,
first_name,
@ -73,6 +89,8 @@ export default class UserStore {
email,
roles,
fullname,
about,
last_login,
likes,
contacts,
courses,

View File

@ -134,6 +134,7 @@ export type CreateUserParamsType = {
password: string;
};
export type UpdateUserParamsType = { first_name?: string; last_name?: string; email?: string };
export type UpdateUserContactsParamsType = {
phone_number?: string | null | undefined;
twitter_id?: string | null | undefined;
@ -141,6 +142,10 @@ export type UpdateUserContactsParamsType = {
vk_id?: string | null | undefined;
};
export type UpdateUserInfoParamsType = {
about?: string | null | undefined;
} & UpdateUserContactsParamsType;
export type CreateCourseParamsType = {
title: string;
description: string;