Commit 1d870916 authored by Igor Stuev's avatar Igor Stuev Committed by GitHub

Merge pull request #1127 from blockscout/nft-and-qr-modals

Nft and qr modals
parents a4dfabcf 85b0c40c
......@@ -72,6 +72,14 @@ const baseStyle = definePartsStyle((props) => ({
}));
const sizes = {
sm: definePartsStyle({
dialogContainer: {
height: '100%',
},
dialog: {
maxW: '536px',
},
}),
md: definePartsStyle({
dialogContainer: {
height: '100%',
......
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import * as addressMock from 'mocks/address/address';
import TestApp from 'playwright/TestApp';
import AddressQrCode from './AddressQrCode';
......@@ -8,7 +9,7 @@ import AddressQrCode from './AddressQrCode';
test('default view +@mobile +@dark-mode', async({ mount, page }) => {
await mount(
<TestApp>
<AddressQrCode hash="0x363574E6C5C71c343d7348093D84320c76d5Dd29"/>
<AddressQrCode address={ addressMock.withoutName }/>
</TestApp>,
);
await page.getByRole('button', { name: /qr code/i }).click();
......
......@@ -6,7 +6,9 @@ import {
ModalBody,
ModalContent,
ModalCloseButton,
ModalHeader,
ModalOverlay,
LightMode,
Box,
useDisclosure,
Tooltip,
......@@ -18,10 +20,12 @@ import { useRouter } from 'next/router';
import QRCode from 'qrcode';
import React from 'react';
import type { Address as AddressType } from 'types/api/address';
import qrCodeIcon from 'icons/qr_code.svg';
import useIsMobile from 'lib/hooks/useIsMobile';
import getPageType from 'lib/mixpanel/getPageType';
import * as mixpanel from 'lib/mixpanel/index';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
const SVG_OPTIONS = {
margin: 0,
......@@ -29,13 +33,13 @@ const SVG_OPTIONS = {
interface Props {
className?: string;
hash: string;
address: AddressType;
isLoading?: boolean;
}
const AddressQrCode = ({ hash, className, isLoading }: Props) => {
const AddressQrCode = ({ address, className, isLoading }: Props) => {
const { isOpen, onOpen, onClose } = useDisclosure();
const isMobile = useIsMobile();
const router = useRouter();
const [ qr, setQr ] = React.useState('');
......@@ -45,7 +49,7 @@ const AddressQrCode = ({ hash, className, isLoading }: Props) => {
React.useEffect(() => {
if (isOpen) {
QRCode.toString(hash, SVG_OPTIONS, (error: Error | null | undefined, svg: string) => {
QRCode.toString(address.hash, SVG_OPTIONS, (error: Error | null | undefined, svg: string) => {
if (error) {
setError('We were unable to generate QR code.');
Sentry.captureException(error, { tags: { source: 'qr_code' } });
......@@ -57,7 +61,7 @@ const AddressQrCode = ({ hash, className, isLoading }: Props) => {
mixpanel.logEvent(mixpanel.EventTypes.QR_CODE, { 'Page type': pageType });
});
}
}, [ hash, isOpen, onClose, pageType ]);
}, [ address.hash, isOpen, onClose, pageType ]);
if (isLoading) {
return <Skeleton className={ className } w="36px" h="32px" borderRadius="base"/>;
......@@ -77,15 +81,38 @@ const AddressQrCode = ({ hash, className, isLoading }: Props) => {
icon={ <Icon as={ qrCodeIcon } boxSize={ 5 }/> }
/>
</Tooltip>
{ error && (
<Modal isOpen={ isOpen } onClose={ onClose } size={{ base: 'full', lg: 'sm' }}>
<ModalOverlay/>
<ModalContent>
<ModalBody mb={ 0 }>
<Alert status="warning">{ error }</Alert>
</ModalBody>
</ModalContent>
</Modal>
) }
{ !error && (
<LightMode>
<Modal isOpen={ isOpen } onClose={ onClose } size={{ base: 'full', lg: 'sm' }}>
<ModalOverlay/>
<ModalContent bgColor={ error ? undefined : 'white' }>
{ isMobile && <ModalCloseButton/> }
<ModalContent>
<ModalHeader fontWeight="500" textStyle="h3" mb={ 4 }>Address QR code</ModalHeader>
<ModalCloseButton/>
<ModalBody mb={ 0 }>
{ error ? <Alert status="warning">{ error }</Alert> : <Box dangerouslySetInnerHTML={{ __html: qr }}/> }
<AddressEntity
mb={ 3 }
fontWeight={ 500 }
color="text"
address={ address }
noLink
/>
<Box p={ 4 } dangerouslySetInnerHTML={{ __html: qr }}/>
</ModalBody>
</ModalContent>
</Modal>
</LightMode>
) }
</>
);
};
......
import { Flex, Link, Text, LinkBox, LinkOverlay, useColorModeValue, Skeleton } from '@chakra-ui/react';
import { Box, Flex, Link, Text, useColorModeValue, Skeleton } from '@chakra-ui/react';
import React from 'react';
import type { AddressTokenBalance } from 'types/api/address';
......@@ -12,27 +12,26 @@ import TruncatedTextTooltip from 'ui/shared/TruncatedTextTooltip';
type Props = AddressTokenBalance & { isLoading: boolean };
const NFTItem = ({ token, token_id: tokenId, token_instance: tokenInstance, isLoading }: Props) => {
const tokenLink = route({ pathname: '/token/[hash]', query: { hash: token.address } });
const tokenInstanceLink = tokenId ? route({ pathname: '/token/[hash]/instance/[id]', query: { hash: token.address, id: tokenId } }) : undefined;
return (
<LinkBox
<Box
w={{ base: '100%', lg: '210px' }}
border="1px solid"
borderColor={ useColorModeValue('blackAlpha.100', 'whiteAlpha.200') }
borderRadius="12px"
p="10px"
_hover={{ boxShadow: 'md' }}
fontSize="sm"
fontWeight={ 500 }
lineHeight="20px"
>
<LinkOverlay href={ isLoading ? undefined : tokenLink }>
<Link href={ isLoading ? undefined : tokenInstanceLink }>
<NftMedia
mb="18px"
url={ tokenInstance?.animation_url || tokenInstance?.image_url || null }
isLoading={ isLoading }
/>
</LinkOverlay>
</Link>
{ tokenId && (
<Flex mb={ 2 } ml={ 1 }>
<Text whiteSpace="pre" variant="secondary">ID# </Text>
......@@ -44,7 +43,7 @@ const NFTItem = ({ token, token_id: tokenId, token_instance: tokenInstance, isLo
whiteSpace="nowrap"
textOverflow="ellipsis"
overflow="hidden"
href={ route({ pathname: '/token/[hash]/instance/[id]', query: { hash: token.address, id: tokenId } }) }
href={ tokenInstanceLink }
>
{ tokenId }
</Link>
......@@ -58,7 +57,7 @@ const NFTItem = ({ token, token_id: tokenId, token_instance: tokenInstance, isLo
noCopy
noSymbol
/>
</LinkBox>
</Box>
);
};
......
......@@ -37,7 +37,7 @@ const AddressHeadingInfo = ({ address, token, isLinkDisabled, isLoading }: Props
{ !isLoading && !address.is_contract && config.features.account.isEnabled && (
<AddressFavoriteButton hash={ address.hash } watchListId={ address.watchlist_address_id } ml={ 3 }/>
) }
<AddressQrCode hash={ address.hash } ml={ 2 } isLoading={ isLoading } flexShrink={ 0 }/>
<AddressQrCode address={ address } ml={ 2 } isLoading={ isLoading } flexShrink={ 0 }/>
{ config.features.account.isEnabled && <AddressActionsMenu isLoading={ isLoading }/> }
</Flex>
);
......
import { Icon, useColorModeValue } from '@chakra-ui/react';
import { Icon, useColorModeValue, chakra } from '@chakra-ui/react';
import React from 'react';
import nftIcon from 'icons/nft_shield.svg';
const NftFallback = () => {
const NftFallback = ({ className }: {className?: string}) => {
return (
<Icon
className={ className }
as={ nftIcon }
p="50px"
color={ useColorModeValue('blackAlpha.500', 'whiteAlpha.500') }
bgColor={ useColorModeValue('blackAlpha.50', 'whiteAlpha.50') }
/>
);
};
export default NftFallback;
export default chakra(NftFallback);
import { chakra } from '@chakra-ui/react';
import { chakra, LinkOverlay } from '@chakra-ui/react';
import React from 'react';
import { mediaStyleProps } from './utils';
interface Props {
src: string;
onLoad: () => void;
onError: () => void;
onClick?: () => void;
}
const NftHtml = ({ src, onLoad, onError }: Props) => {
const NftHtml = ({ src, onLoad, onError, onClick }: Props) => {
return (
<LinkOverlay
onClick={ onClick }
{ ...mediaStyleProps }
>
<chakra.iframe
src={ src }
h="100%"
w="100%"
sandbox="allow-scripts"
onLoad={ onLoad }
onError={ onError }
/>
</LinkOverlay>
);
};
......
import { chakra } from '@chakra-ui/react';
import React from 'react';
import NftMediaFullscreenModal from './NftMediaFullscreenModal';
interface Props {
src: string;
isOpen: boolean;
onClose: () => void;
}
const NftHtmlWithFullscreen = ({ src, isOpen, onClose }: Props) => {
return (
<NftMediaFullscreenModal isOpen={ isOpen } onClose={ onClose }>
<chakra.iframe
w="90vw"
h="90vh"
src={ src }
sandbox="allow-scripts"
/>
</NftMediaFullscreenModal>
);
};
export default NftHtmlWithFullscreen;
import { chakra, useDisclosure } from '@chakra-ui/react';
import React from 'react';
import NftHtml from './NftHtml';
import NftMediaFullscreenModal from './NftMediaFullscreenModal';
interface Props {
src: string;
onLoad: () => void;
onError: () => void;
}
const NftHtmlWithFullscreen = ({ src, onLoad, onError }: Props) => {
const { isOpen, onOpen, onClose } = useDisclosure();
return (
<>
<NftHtml src={ src } onLoad={ onLoad } onError={ onError } onClick={ onOpen }/>
<NftMediaFullscreenModal isOpen={ isOpen } onClose={ onClose }>
<chakra.iframe
w="90vw"
h="90vh"
src={ src }
sandbox="allow-scripts"
onLoad={ onLoad }
onError={ onError }
/>
</NftMediaFullscreenModal>
</>
);
};
export default NftHtmlWithFullscreen;
import { Image } from '@chakra-ui/react';
import React from 'react';
import { mediaStyleProps } from './utils';
interface Props {
url: string;
src: string;
onLoad: () => void;
onError: () => void;
onClick?: () => void;
}
const NftImage = ({ url, onLoad, onError }: Props) => {
const NftImage = ({ src, onLoad, onError, onClick }: Props) => {
return (
<Image
w="100%"
h="100%"
src={ url }
src={ src }
alt="Token instance image"
onError={ onError }
onLoad={ onLoad }
onClick={ onClick }
{ ...mediaStyleProps }
/>
);
};
......
import {
Image,
} from '@chakra-ui/react';
import React from 'react';
import NftMediaFullscreenModal from './NftMediaFullscreenModal';
interface Props {
src: string;
isOpen: boolean;
onClose: () => void;
}
const NftImageWithFullscreen = ({ src, isOpen, onClose }: Props) => {
return (
<NftMediaFullscreenModal isOpen={ isOpen } onClose={ onClose }>
<Image src={ src } alt="Token instance image" maxH="90vh" maxW="90vw"/>
</NftMediaFullscreenModal>
);
};
export default NftImageWithFullscreen;
import {
Image,
useDisclosure,
} from '@chakra-ui/react';
import React from 'react';
import NftImage from './NftImage';
import NftMediaFullscreenModal from './NftMediaFullscreenModal';
interface Props {
src: string;
onLoad: () => void;
onError: () => void;
}
const NftImageWithFullscreen = ({ src, onLoad, onError }: Props) => {
const { isOpen, onOpen, onClose } = useDisclosure();
return (
<>
<NftImage src={ src } onLoad={ onLoad } onError={ onError } onClick={ onOpen }/>
<NftMediaFullscreenModal isOpen={ isOpen } onClose={ onClose }>
<Image src={ src } alt="Token instance image" maxH="90vh" maxW="90vw"/>
</NftMediaFullscreenModal>
</>
);
};
export default NftImageWithFullscreen;
......@@ -5,9 +5,9 @@ import TestApp from 'playwright/TestApp';
import NftMedia from './NftMedia';
test.use({ viewport: { width: 250, height: 250 } });
test('no url +@dark-mode', async({ mount }) => {
test.describe('no url', () => {
test.use({ viewport: { width: 250, height: 250 } });
test('preview +@dark-mode', async({ mount }) => {
const component = await mount(
<TestApp>
<NftMedia url={ null }/>
......@@ -15,17 +15,24 @@ test('no url +@dark-mode', async({ mount }) => {
);
await expect(component).toHaveScreenshot();
});
});
test('image +@dark-mode', async({ mount, page }) => {
test.describe('image', () => {
test.use({ viewport: { width: 250, height: 250 } });
const MEDIA_URL = 'https://localhost:3000/my-image.jpg';
test.beforeEach(async({ page }) => {
await page.route(MEDIA_URL, (route) => {
return route.fulfill({
status: 200,
path: './playwright/mocks/image_long.jpg',
});
});
});
test('preview +@dark-mode', async({ mount }) => {
const component = await mount(
<TestApp>
<NftMedia url={ MEDIA_URL }/>
......@@ -33,12 +40,58 @@ test('image +@dark-mode', async({ mount, page }) => {
);
await expect(component).toHaveScreenshot();
});
});
test('page', async({ mount, page }) => {
test('image preview hover', async({ mount, page }) => {
const MEDIA_URL = 'https://localhost:3000/my-image.jpg';
await page.route(MEDIA_URL, (route) => {
return route.fulfill({
status: 200,
path: './playwright/mocks/image_long.jpg',
});
});
const component = await mount(
<TestApp>
<NftMedia url={ MEDIA_URL } w="250px"/>
</TestApp>,
);
await component.getByAltText('Token instance image').hover();
await expect(page).toHaveScreenshot({ clip: { x: 0, y: 0, width: 250, height: 250 } });
});
test('image fullscreen +@dark-mode +@mobile', async({ mount, page }) => {
const MEDIA_URL = 'https://localhost:3000/my-image.jpg';
await page.route(MEDIA_URL, (route) => {
return route.fulfill({
status: 200,
path: './playwright/mocks/image_long.jpg',
});
});
const component = await mount(
<TestApp>
<NftMedia url={ MEDIA_URL } withFullscreen w="250px"/>
</TestApp>,
);
await component.getByAltText('Token instance image').click();
await expect(page).toHaveScreenshot();
});
test.describe('page', () => {
test.use({ viewport: { width: 250, height: 250 } });
const MEDIA_URL = 'https://localhost:3000/page.html';
const MEDIA_TYPE_API_URL = `/node-api/media-type?url=${ encodeURIComponent(MEDIA_URL) }`;
test.beforeEach(async({ page }) => {
await page.route(MEDIA_URL, (route) => {
return route.fulfill({
status: 200,
......@@ -49,7 +102,9 @@ test('page', async({ mount, page }) => {
status: 200,
body: JSON.stringify({ type: 'html' }),
}));
});
test('preview +@dark-mode', async({ mount }) => {
const component = await mount(
<TestApp>
<NftMedia url={ MEDIA_URL }/>
......@@ -57,4 +112,5 @@ test('page', async({ mount, page }) => {
);
await expect(component).toHaveScreenshot();
});
});
import { AspectRatio, chakra, Skeleton, useColorModeValue } from '@chakra-ui/react';
import { AspectRatio, chakra, Skeleton, useDisclosure } from '@chakra-ui/react';
import React from 'react';
import { useInView } from 'react-intersection-observer';
import NftFallback from './NftFallback';
import NftHtml from './NftHtml';
import NftHtmlFullscreen from './NftHtmlFullscreen';
import NftImage from './NftImage';
import NftImageFullscreen from './NftImageFullscreen';
import NftVideo from './NftVideo';
import NftVideoFullscreen from './NftVideoFullscreen';
import useNftMediaType from './useNftMediaType';
import { mediaStyleProps } from './utils';
interface Props {
url: string | null;
className?: string;
isLoading?: boolean;
withFullscreen?: boolean;
}
const NftMedia = ({ url, className, isLoading }: Props) => {
const NftMedia = ({ url, className, isLoading, withFullscreen }: Props) => {
const [ isMediaLoading, setIsMediaLoading ] = React.useState(Boolean(url));
const [ isLoadingError, setIsLoadingError ] = React.useState(false);
const bgColor = useColorModeValue('blackAlpha.50', 'whiteAlpha.50');
const { ref, inView } = useInView({ triggerOnce: true });
const type = useNftMediaType(url, !isLoading && inView);
const handleMediaLoaded = React.useCallback(() => {
......@@ -32,18 +36,51 @@ const NftMedia = ({ url, className, isLoading }: Props) => {
setIsLoadingError(true);
}, []);
const { isOpen, onOpen, onClose } = useDisclosure();
const content = (() => {
if (!url || isLoadingError) {
return <NftFallback/>;
const styleProps = withFullscreen ? {} : mediaStyleProps;
return <NftFallback { ...styleProps }/>;
}
const props = {
src: url,
onLoad: handleMediaLoaded,
onError: handleMediaLoadError,
...(withFullscreen ? { onClick: onOpen } : {}),
};
switch (type) {
case 'video':
return <NftVideo { ...props }/>;
case 'html':
return <NftHtml { ...props }/>;
case 'image':
return <NftImage { ...props }/>;
default:
return null;
}
})();
const modal = (() => {
if (!url || !withFullscreen) {
return null;
}
const props = {
src: url,
isOpen,
onClose,
};
switch (type) {
case 'video':
return <NftVideo src={ url } onLoad={ handleMediaLoaded } onError={ handleMediaLoadError }/>;
return <NftVideoFullscreen { ...props }/>;
case 'html':
return <NftHtml src={ url } onLoad={ handleMediaLoaded } onError={ handleMediaLoadError }/>;
return <NftHtmlFullscreen { ...props }/>;
case 'image':
return <NftImage url={ url } onLoad={ handleMediaLoaded } onError={ handleMediaLoadError }/>;
return <NftImageFullscreen { ...props }/>;
default:
return null;
}
......@@ -53,11 +90,11 @@ const NftMedia = ({ url, className, isLoading }: Props) => {
<AspectRatio
ref={ ref }
className={ className }
bgColor={ isLoading || isMediaLoading ? 'transparent' : bgColor }
ratio={ 1 / 1 }
overflow="hidden"
borderRadius="md"
objectFit="contain"
isolation="isolate"
sx={{
'&>img, &>video': {
objectFit: 'contain',
......@@ -66,6 +103,7 @@ const NftMedia = ({ url, className, isLoading }: Props) => {
>
<>
{ content }
{ modal }
{ isMediaLoading && <Skeleton position="absolute" left={ 0 } top={ 0 } w="100%" h="100%" zIndex="1"/> }
</>
</AspectRatio>
......
import {
Modal,
ModalContent,
ModalCloseButton,
ModalOverlay,
} from '@chakra-ui/react';
import React from 'react';
interface Props {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
}
const NftMediaFullscreenModal = ({ isOpen, onClose, children }: Props) => {
return (
<Modal isOpen={ isOpen } onClose={ onClose } motionPreset="none">
<ModalOverlay/>
<ModalContent w="unset" maxW="100vw" p={ 0 } background="none" boxShadow="none">
<ModalCloseButton position="fixed" top={{ base: 2.5, lg: 8 }} right={{ base: 2.5, lg: 8 }} color="whiteAlpha.800"/>
{ children }
</ModalContent>
</Modal>
);
};
export default NftMediaFullscreenModal;
import { chakra } from '@chakra-ui/react';
import React from 'react';
import { mediaStyleProps, videoPlayProps } from './utils';
interface Props {
src: string;
onLoad: () => void;
onError: () => void;
onClick?: () => void;
}
const NftVideo = ({ src, onLoad, onError }: Props) => {
const NftVideo = ({ src, onLoad, onError, onClick }: Props) => {
return (
<chakra.video
{ ...videoPlayProps }
src={ src }
autoPlay
disablePictureInPicture
loop
muted
playsInline
onCanPlayThrough={ onLoad }
onError={ onError }
borderRadius="md"
onClick={ onClick }
{ ...mediaStyleProps }
/>
);
};
......
import { chakra } from '@chakra-ui/react';
import React from 'react';
import NftMediaFullscreenModal from './NftMediaFullscreenModal';
import { videoPlayProps } from './utils';
interface Props {
src: string;
isOpen: boolean;
onClose: () => void;
}
const NftVideoWithFullscreen = ({ src, isOpen, onClose }: Props) => {
return (
<NftMediaFullscreenModal isOpen={ isOpen } onClose={ onClose }>
<chakra.video
{ ...videoPlayProps }
src={ src }
maxH="90vh"
maxW="90vw"
/>
</NftMediaFullscreenModal>
);
};
export default NftVideoWithFullscreen;
import {
chakra,
useDisclosure,
} from '@chakra-ui/react';
import React from 'react';
import NftMediaFullscreenModal from './NftMediaFullscreenModal';
import NftVideo from './NftVideo';
import { videoPlayProps } from './utils';
interface Props {
src: string;
onLoad: () => void;
onError: () => void;
}
const NftVideoWithFullscreen = ({ src, onLoad, onError }: Props) => {
const { isOpen, onOpen, onClose } = useDisclosure();
return (
<>
<NftVideo src={ src } onLoad={ onLoad } onError={ onError } onClick={ onOpen }/>
<NftMediaFullscreenModal isOpen={ isOpen } onClose={ onClose }>
<chakra.video
{ ...videoPlayProps }
src={ src }
onCanPlayThrough={ onLoad }
onError={ onError }
maxH="90vh"
maxW="90vw"
/>
</NftMediaFullscreenModal>
</>
);
};
export default NftVideoWithFullscreen;
......@@ -26,3 +26,24 @@ export function getPreliminaryMediaType(url: string): MediaType | undefined {
return 'video';
}
}
export const mediaStyleProps = {
transitionProperty: 'transform',
transitionDuration: 'normal',
transitionTimingFunction: 'ease',
cursor: 'pointer',
_hover: {
base: {},
lg: {
transform: 'scale(1.2)',
},
},
};
export const videoPlayProps = {
autoPlay: true,
disablePictureInPicture: true,
loop: true,
muted: true,
playsInline: true,
};
import { Flex, Text, LinkBox, LinkOverlay, useColorModeValue, Skeleton } from '@chakra-ui/react';
import NextLink from 'next/link';
import { Box, Flex, Text, Link, useColorModeValue, Skeleton } from '@chakra-ui/react';
import React from 'react';
import type { TokenInstance } from 'types/api/token';
import { route } from 'nextjs-routes';
import useIsMobile from 'lib/hooks/useIsMobile';
import AddressEntity from 'ui/shared/entities/address/AddressEntity';
import LinkInternal from 'ui/shared/LinkInternal';
......@@ -24,29 +25,22 @@ const NFTItem = ({ item, isLoading }: Props) => {
/>
);
const url = route({ pathname: '/token/[hash]/instance/[id]', query: { hash: item.token.address, id: item.id } });
return (
<LinkBox
<Box
w={{ base: '100%', lg: '210px' }}
border="1px solid"
borderColor={ useColorModeValue('blackAlpha.100', 'whiteAlpha.200') }
borderRadius="12px"
p="10px"
_hover={{ boxShadow: isLoading ? 'none' : 'md' }}
fontSize="sm"
fontWeight={ 500 }
lineHeight="20px"
>
{ isLoading ? mediaElement : (
<NextLink
href={{ pathname: '/token/[hash]/instance/[id]', query: { hash: item.token.address, id: item.id } }}
passHref
legacyBehavior
>
<LinkOverlay>
<Link href={ isLoading ? undefined : url }>
{ mediaElement }
</LinkOverlay>
</NextLink>
) }
</Link>
{ item.id && (
<Flex mb={ 2 } ml={ 1 }>
<Text whiteSpace="pre" variant="secondary">ID# </Text>
......@@ -58,6 +52,7 @@ const NFTItem = ({ item, isLoading }: Props) => {
whiteSpace="nowrap"
display="block"
isLoading={ isLoading }
href={ url }
>
{ item.id }
</LinkInternal>
......@@ -77,7 +72,7 @@ const NFTItem = ({ item, isLoading }: Props) => {
/>
</Flex>
) }
</LinkBox>
</Box>
);
};
......
......@@ -88,6 +88,7 @@ const TokenInstanceDetails = ({ data, scrollRef, isLoading }: Props) => {
flexShrink={ 0 }
alignSelf={{ base: 'center', lg: 'flex-start' }}
isLoading={ isLoading }
withFullscreen
/>
</Flex>
<Grid
......
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