Commit c0637440 authored by tom goriunov's avatar tom goriunov Committed by GitHub

Merge pull request #111 from blockscout/profile-menu

Profile menu and page
parents 45f6a946 4c3d7231
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 30 30">
<path d="M15 25c5.523 0 10-4.477 10-10S20.523 5 15 5 5 9.477 5 15s4.477 10 10 10Z" stroke="currentColor" stroke-width="2" stroke-miterlimit="10" stroke-linejoin="round"/>
<path d="M21.667 22.333a6.666 6.666 0 1 0-13.334 0" stroke="currentColor" stroke-width="2" stroke-miterlimit="10" stroke-linejoin="round"/>
<path d="M15 15.667a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z" stroke="currentColor" stroke-width="2" stroke-miterlimit="10" stroke-linejoin="round"/>
</svg>
\ No newline at end of file
import { useQuery } from '@tanstack/react-query';
import type { UserInfo } from 'types/api/account';
import fetch from 'lib/client/fetch';
export default function useFetchProfileInfo() {
return useQuery<unknown, unknown, UserInfo>([ 'profile' ], async() => {
return fetch('/api/account/profile');
}, { refetchOnMount: false });
}
...@@ -6,6 +6,7 @@ import appsIcon from 'icons/apps.svg'; ...@@ -6,6 +6,7 @@ import appsIcon from 'icons/apps.svg';
import blocksIcon from 'icons/block.svg'; import blocksIcon from 'icons/block.svg';
import gearIcon from 'icons/gear.svg'; import gearIcon from 'icons/gear.svg';
import privateTagIcon from 'icons/privattags.svg'; import privateTagIcon from 'icons/privattags.svg';
import profileIcon from 'icons/profile.svg';
import publicTagIcon from 'icons/publictags.svg'; import publicTagIcon from 'icons/publictags.svg';
import tokensIcon from 'icons/token.svg'; import tokensIcon from 'icons/token.svg';
import transactionsIcon from 'icons/transactions.svg'; import transactionsIcon from 'icons/transactions.svg';
...@@ -32,6 +33,8 @@ export default function useNavItems() { ...@@ -32,6 +33,8 @@ export default function useNavItems() {
{ text: 'Custom ABI', pathname: basePath + '/account/custom_abi', icon: abiIcon }, { text: 'Custom ABI', pathname: basePath + '/account/custom_abi', icon: abiIcon },
]; ];
return { mainNavItems, accountNavItems }; const profileItem = { text: 'My profile', pathname: basePath + '/auth/profile', icon: profileIcon };
return { mainNavItems, accountNavItems, profileItem };
}, [ basePath ]); }, [ basePath ]);
} }
import type { NextPage } from 'next';
import Head from 'next/head';
import React from 'react';
import MyProfile from 'ui/pages/MyProfile';
const MyProfilePage: NextPage = () => {
return (
<>
<Head><title>My profile</title></Head>
<MyProfile/>
</>
);
};
export default MyProfilePage;
import type { UserInfo } from 'types/api/account';
import handler from 'lib/api/handler';
const profileHandler = handler<UserInfo, unknown>(() => '/account/v1/user/info', [ 'GET' ]);
export default profileHandler;
...@@ -17,18 +17,20 @@ const variantPrimary = { ...@@ -17,18 +17,20 @@ const variantPrimary = {
}, },
}; };
const variantSecondary = { const variantSecondary: SystemStyleFunction = (props) => {
color: 'blue.600', return {
fontWeight: 600, color: mode('blue.600', 'blue.300')(props),
borderColor: 'blue.600', fontWeight: 600,
border: '2px solid', borderColor: mode('blue.600', 'blue.300')(props),
_hover: { border: '2px solid',
color: 'blue.400', _hover: {
borderColor: 'blue.400', color: 'blue.400',
}, borderColor: 'blue.400',
_disabled: { },
opacity: 0.2, _disabled: {
}, opacity: 0.2,
},
};
}; };
const variantIcon: SystemStyleFunction = (props) => { const variantIcon: SystemStyleFunction = (props) => {
......
...@@ -2,7 +2,11 @@ import type { drawerAnatomy as parts } from '@chakra-ui/anatomy'; ...@@ -2,7 +2,11 @@ import type { drawerAnatomy as parts } from '@chakra-ui/anatomy';
import type { SystemStyleFunction, PartsStyleFunction, SystemStyleObject } from '@chakra-ui/theme-tools'; import type { SystemStyleFunction, PartsStyleFunction, SystemStyleObject } from '@chakra-ui/theme-tools';
import { mode } from '@chakra-ui/theme-tools'; import { mode } from '@chakra-ui/theme-tools';
import getDefaultTransitionProps from '../utils/getDefaultTransitionProps';
const transitionProps = getDefaultTransitionProps();
const baseStyleOverlay: SystemStyleObject = { const baseStyleOverlay: SystemStyleObject = {
...transitionProps,
bg: 'blackAlpha.800', bg: 'blackAlpha.800',
zIndex: 'overlay', zIndex: 'overlay',
}; };
...@@ -12,6 +16,7 @@ const baseStyleDialog: SystemStyleFunction = (props) => { ...@@ -12,6 +16,7 @@ const baseStyleDialog: SystemStyleFunction = (props) => {
return { return {
...(isFullHeight && { height: '100vh' }), ...(isFullHeight && { height: '100vh' }),
...transitionProps,
zIndex: 'modal', zIndex: 'modal',
maxH: '100vh', maxH: '100vh',
bg: mode('white', 'gray.900')(props), bg: mode('white', 'gray.900')(props),
......
...@@ -51,7 +51,8 @@ export type Transactions = Array<Transaction> ...@@ -51,7 +51,8 @@ export type Transactions = Array<Transaction>
export interface UserInfo { export interface UserInfo {
name?: string; name?: string;
nickname?: string; nickname?: string;
email?: string; email: string;
avatar?: string;
} }
export interface WatchlistAddress { export interface WatchlistAddress {
......
...@@ -3,11 +3,12 @@ import React from 'react'; ...@@ -3,11 +3,12 @@ import React from 'react';
import useIsMobile from 'lib/hooks/useIsMobile'; import useIsMobile from 'lib/hooks/useIsMobile';
import NetworkLogo from 'ui/blocks/networkMenu/NetworkLogo'; import NetworkLogo from 'ui/blocks/networkMenu/NetworkLogo';
import ProfileMenuDesktop from 'ui/blocks/profileMenu/ProfileMenuDesktop';
import ProfileMenuMobile from 'ui/blocks/profileMenu/ProfileMenuMobile';
import SearchBar from 'ui/blocks/searchBar/SearchBar'; import SearchBar from 'ui/blocks/searchBar/SearchBar';
import Burger from './Burger'; import Burger from './Burger';
import ColorModeToggler from './ColorModeToggler'; import ColorModeToggler from './ColorModeToggler';
import ProfileMenu from './ProfileMenu';
const Header = () => { const Header = () => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
...@@ -31,7 +32,7 @@ const Header = () => { ...@@ -31,7 +32,7 @@ const Header = () => {
> >
<Burger/> <Burger/>
<NetworkLogo/> <NetworkLogo/>
<ProfileMenu/> <ProfileMenuMobile/>
</Flex> </Flex>
<SearchBar/> <SearchBar/>
</Box> </Box>
...@@ -48,7 +49,7 @@ const Header = () => { ...@@ -48,7 +49,7 @@ const Header = () => {
> >
<SearchBar/> <SearchBar/>
<ColorModeToggler/> <ColorModeToggler/>
<ProfileMenu/> <ProfileMenuDesktop/>
</HStack> </HStack>
); );
}; };
......
import { Center, useColorModeValue, chakra } from '@chakra-ui/react';
import React from 'react';
import Identicon from 'react-identicons';
import useIsMobile from 'lib/hooks/useIsMobile';
const ProfileIcon = chakra(Identicon);
const ProfileMenu = () => {
const isMobile = useIsMobile();
const size = isMobile ? '24px' : '50px';
return (
<Center
flexShrink={ 0 }
padding={ isMobile ? 2 : 0 }
>
{ /* the displayed size is 48px, but we need to generate x2 for retina displays */ }
<ProfileIcon
maxWidth={ size }
maxHeight={ size }
string="randomness"
size={ 100 }
bg={ useColorModeValue('blackAlpha.100', 'white') }
borderRadius="50%"
overflow="hidden"
/>
</Center>
);
};
export default ProfileMenu;
...@@ -9,13 +9,14 @@ import useColors from './useColors'; ...@@ -9,13 +9,14 @@ import useColors from './useColors';
interface Props { interface Props {
isCollapsed?: boolean; isCollapsed?: boolean;
isActive: boolean; isActive?: boolean;
pathname: string; pathname: string;
text: string; text: string;
icon: React.FunctionComponent<React.SVGAttributes<SVGElement>>; icon: React.FunctionComponent<React.SVGAttributes<SVGElement>>;
px?: string | number;
} }
const NavLink = ({ text, pathname, icon, isCollapsed, isActive }: Props) => { const NavLink = ({ text, pathname, icon, isCollapsed, isActive, px }: Props) => {
const colors = useColors(); const colors = useColors();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const width = (() => { const width = (() => {
...@@ -32,7 +33,7 @@ const NavLink = ({ text, pathname, icon, isCollapsed, isActive }: Props) => { ...@@ -32,7 +33,7 @@ const NavLink = ({ text, pathname, icon, isCollapsed, isActive }: Props) => {
as="li" as="li"
listStyleType="none" listStyleType="none"
w={ width } w={ width }
px={ isCollapsed ? '15px' : 3 } px={ px || (isCollapsed ? '15px' : 3) }
py={ 2.5 } py={ 2.5 }
color={ isActive ? colors.text.active : colors.text.default } color={ isActive ? colors.text.active : colors.text.default }
bgColor={ isActive ? colors.bg.active : colors.bg.default } bgColor={ isActive ? colors.bg.active : colors.bg.default }
......
import { Box, Button, Text, VStack, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import type { UserInfo } from 'types/api/account';
import useNavItems from 'lib/hooks/useNavItems';
import getDefaultTransitionProps from 'theme/utils/getDefaultTransitionProps';
import NavLink from 'ui/blocks/navigation/NavLink';
type Props = UserInfo;
const ProfileMenuContent = ({ name, nickname, email }: Props) => {
const { accountNavItems, profileItem } = useNavItems();
const borderColor = useColorModeValue('gray.200', 'whiteAlpha.200');
const primaryTextColor = useColorModeValue('gray.600', 'whiteAlpha.800');
return (
<Box>
<Text
fontSize="sm"
fontWeight={ 500 }
color={ primaryTextColor }
{ ...getDefaultTransitionProps() }
>
Signed in as { name || nickname }
</Text>
<Text
fontSize="sm"
mb={ 1 }
fontWeight={ 500 }
color="gray.500"
{ ...getDefaultTransitionProps() }
>
{ email }
</Text>
<NavLink { ...profileItem } px="0px"/>
<Box as="nav" mt={ 2 } pt={ 2 } borderTopColor={ borderColor } borderTopWidth="1px" { ...getDefaultTransitionProps() }>
<VStack as="ul" spacing="0" alignItems="flex-start" overflow="hidden">
{ accountNavItems.map((item) => <NavLink key={ item.text } { ...item } px="0px"/>) }
</VStack>
</Box>
<Box mt={ 2 } pt={ 3 } borderTopColor={ borderColor } borderTopWidth="1px" { ...getDefaultTransitionProps() }>
<Button size="sm" width="full" variant="secondary">Sign Out</Button>
</Box>
</Box>
);
};
export default ProfileMenuContent;
import { Popover, PopoverContent, PopoverBody, PopoverTrigger, Button } from '@chakra-ui/react';
import React from 'react';
import useFetchProfileInfo from 'lib/hooks/useFetchProfileInfo';
import ProfileMenuContent from 'ui/blocks/profileMenu/ProfileMenuContent';
import UserAvatar from 'ui/shared/UserAvatar';
const ProfileMenuDesktop = () => {
const { data } = useFetchProfileInfo();
return (
<Popover openDelay={ 300 } placement="bottom-end" gutter={ 10 } isLazy>
<PopoverTrigger>
<Button variant="unstyled" display="inline-flex" height="auto">
<UserAvatar size={ 50 } data={ data }/>
</Button>
</PopoverTrigger>
{ data && (
<PopoverContent w="212px">
<PopoverBody padding="24px 16px 16px 16px">
<ProfileMenuContent { ...data }/>
</PopoverBody>
</PopoverContent>
) }
</Popover>
);
};
export default ProfileMenuDesktop;
import { Flex, Box, Drawer, DrawerOverlay, DrawerContent, DrawerBody, useDisclosure } from '@chakra-ui/react';
import React from 'react';
import useFetchProfileInfo from 'lib/hooks/useFetchProfileInfo';
import ColorModeToggler from 'ui/blocks/header/ColorModeToggler';
import ProfileMenuContent from 'ui/blocks/profileMenu/ProfileMenuContent';
import UserAvatar from 'ui/shared/UserAvatar';
const ProfileMenuMobile = () => {
const { isOpen, onOpen, onClose } = useDisclosure();
const { data } = useFetchProfileInfo();
return (
<>
<Box padding={ 2 } onClick={ onOpen }>
<UserAvatar size={ 24 } data={ data }/>
</Box>
{ data && (
<Drawer
isOpen={ isOpen }
placement="right"
onClose={ onClose }
autoFocus={ false }
>
<DrawerOverlay/>
<DrawerContent maxWidth="260px">
<DrawerBody p={ 6 }>
<Flex
justifyContent="space-between"
alignItems="center"
mb={ 6 }
>
<ColorModeToggler/>
<Box onClick={ onClose }>
<UserAvatar size={ 24 } data={ data }/>
</Box>
</Flex>
<ProfileMenuContent { ...data }/>
</DrawerBody>
</DrawerContent>
</Drawer>
) }
</>
);
};
export default ProfileMenuMobile;
import { VStack, FormControl, FormLabel, Input } from '@chakra-ui/react';
import React from 'react';
import useFetchProfileInfo from 'lib/hooks/useFetchProfileInfo';
import AccountPageHeader from 'ui/shared/AccountPageHeader';
import ContentLoader from 'ui/shared/ContentLoader';
import DataFetchAlert from 'ui/shared/DataFetchAlert';
import Page from 'ui/shared/Page/Page';
import UserAvatar from 'ui/shared/UserAvatar';
const MyProfile = () => {
const { data, isLoading, isError } = useFetchProfileInfo();
const content = (() => {
if (isLoading) {
return <ContentLoader/>;
}
if (isError) {
return <DataFetchAlert/>;
}
return (
<VStack maxW="412px" mt={ 12 } gap={ 5 } alignItems="stretch">
<UserAvatar size={ 64 } data={ data }/>
<FormControl variant="floating" id="name" isRequired size="lg">
<Input
size="lg"
required
disabled
value={ data.name || '' }
/>
<FormLabel>Name</FormLabel>
</FormControl>
<FormControl variant="floating" id="nickname" isRequired size="lg">
<Input
size="lg"
required
disabled
value={ data.nickname || '' }
/>
<FormLabel>Nickname</FormLabel>
</FormControl>
<FormControl variant="floating" id="email" isRequired size="lg">
<Input
size="lg"
required
disabled
value={ data.email }
/>
<FormLabel>Email</FormLabel>
</FormControl>
</VStack>
);
})();
return (
<Page>
<AccountPageHeader text="My profile"/>
{ content }
</Page>
);
};
export default MyProfile;
import { useColorModeValue, chakra, Image } from '@chakra-ui/react';
import React from 'react';
import Identicon from 'react-identicons';
import type { UserInfo } from 'types/api/account';
const ProfileIcon = chakra(Identicon);
interface Props {
size: number;
data?: UserInfo;
}
const UserAvatar = ({ size, data }: Props) => {
const sizeString = `${ size }px`;
const bgColor = useColorModeValue('blackAlpha.100', 'white');
if (data?.avatar) {
return (
<Image
flexShrink={ 0 }
src={ data.avatar }
alt={ `Profile picture of ${ data.name || data.nickname || '' }` }
w={ sizeString }
minW={ sizeString }
h={ sizeString }
minH={ sizeString }
borderRadius="full"
overflow="hidden"
/>
);
}
return (
<ProfileIcon
flexShrink={ 0 }
maxWidth={ sizeString }
maxHeight={ sizeString }
string={ data?.email || 'randomness' }
// the displayed size is doubled for retina displays
size={ size * 2 }
bg={ bgColor }
borderRadius="full"
overflow="hidden"
/>
);
};
export default React.memo(UserAvatar);
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment