feature: Added Skeleton states

Added skeleton loading states to project mini card, project card, and project rows
This commit is contained in:
MTG2000
2022-01-12 20:54:48 +02:00
parent ac8b71ce99
commit 1d71897e62
20 changed files with 498 additions and 42 deletions

View File

@@ -1,19 +1,23 @@
import { motion } from 'framer-motion';
import { AnimatePresence, motion } from 'framer-motion';
import Modal from 'src/Components/Modals/Modal/Modal';
export const ModalsDecorator = (Story: any) => {
const onClose = () => { };
return (
<motion.div
initial={false}
className="w-screen fixed inset-0 overflow-x-hidden z-[2020]"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className='w-screen h-full fixed inset-0 py-32 md:py-64 flex flex-col justify-center items-center overflow-x-hidden overflow-y-hidden no-scrollbar'
exit={{
opacity: 0,
transition: { ease: "easeInOut" },
}}
>
<div
className="w-screen h-full bg-gray-300 bg-opacity-50 absolute inset-0"
onClick={onClose}
></div>
<Story />
<AnimatePresence>
<Modal onClose={onClose} >
<Story onClose={onClose} />
</Modal>
</AnimatePresence>
</motion.div>
);
}

View File

@@ -3,12 +3,28 @@ import { configure, addDecorator } from "@storybook/react";
import { store } from "../src/redux/store";
import React from "react";
import { Provider } from "react-redux";
import { QueryClient, QueryClientProvider } from "react-query";
import {
ApolloClient,
InMemoryCache,
ApolloProvider
} from "@apollo/client";
import "react-multi-carousel/lib/styles.css";
import "react-loader-spinner/dist/loader/css/react-spinner-loader.css";
import 'react-loading-skeleton/dist/skeleton.css'
import { BrowserRouter } from "react-router-dom";
const queryClient = new QueryClient();
const client = new ApolloClient({
uri: 'https://deploy-preview-2--makers-bolt-fun.netlify.app/.netlify/functions/graphql',
cache: new InMemoryCache()
});
export const parameters = {
actions: { argTypesRegex: "^on[A-Z].*" },
@@ -21,13 +37,14 @@ export const parameters = {
};
addDecorator((S) => (
<QueryClientProvider client={queryClient}>
<ApolloProvider client={client}>
<Provider store={store}>
<BrowserRouter>
<S />
</BrowserRouter>
</Provider>
</QueryClientProvider>
</ApolloProvider>
));
configure(require.context("../src", true, /\.stories\.ts$/), module);

15
package-lock.json generated
View File

@@ -33,6 +33,7 @@
"react-dom": "^17.0.2",
"react-icons": "^4.3.1",
"react-loader-spinner": "^4.0.0",
"react-loading-skeleton": "^3.0.2",
"react-multi-carousel": "^2.6.5",
"react-query": "^3.32.3",
"react-redux": "^7.2.6",
@@ -47620,6 +47621,14 @@
"react-dom": "*"
}
},
"node_modules/react-loading-skeleton": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/react-loading-skeleton/-/react-loading-skeleton-3.0.2.tgz",
"integrity": "sha512-rlALwuZEcjazUDeIy3+fEhm20Uk9Yd/zZGeITU034K2ts5/yEf7RuZaV2FyrPWypIII4LAsFEo9WDTPKp6G0fQ==",
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/react-multi-carousel": {
"version": "2.6.5",
"integrity": "sha512-i5iuAm5XRT/h7uBL9/pGWeRsQXzqvjBrPVP1sobKgDKEvfZuKFpYp/alaQhTRM56Jtkb8jZpSqLn52Ku6jJbDg==",
@@ -89771,6 +89780,12 @@
"prop-types": "^15.7.2"
}
},
"react-loading-skeleton": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/react-loading-skeleton/-/react-loading-skeleton-3.0.2.tgz",
"integrity": "sha512-rlALwuZEcjazUDeIy3+fEhm20Uk9Yd/zZGeITU034K2ts5/yEf7RuZaV2FyrPWypIII4LAsFEo9WDTPKp6G0fQ==",
"requires": {}
},
"react-multi-carousel": {
"version": "2.6.5",
"integrity": "sha512-i5iuAm5XRT/h7uBL9/pGWeRsQXzqvjBrPVP1sobKgDKEvfZuKFpYp/alaQhTRM56Jtkb8jZpSqLn52Ku6jJbDg=="

View File

@@ -29,6 +29,7 @@
"react-dom": "^17.0.2",
"react-icons": "^4.3.1",
"react-loader-spinner": "^4.0.0",
"react-loading-skeleton": "^3.0.2",
"react-multi-carousel": "^2.6.5",
"react-query": "^3.32.3",
"react-redux": "^7.2.6",

View File

@@ -0,0 +1,70 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import Badge from './Badge';
export default {
title: 'Shared/Badge',
component: Badge,
argTypes: {
backgroundColor: { control: 'color' },
},
} as ComponentMeta<typeof Badge>;
const Template: ComponentStory<typeof Badge> = (args) => <Badge {...args} >Badge</Badge>
export const Default = Template.bind({});
export const Primary = Template.bind({});
Primary.args = {
color: 'primary'
}
export const SmallSize = Template.bind({});
SmallSize.args = {
color: 'primary',
size: 'sm'
}
export const MediumSize = Template.bind({});
MediumSize.args = {
color: 'primary',
size: 'md'
}
export const LargeSize = Template.bind({});
LargeSize.args = {
color: 'primary',
size: 'lg'
}
export const Removable = Template.bind({});
Removable.args = {
color: 'primary',
onRemove: () => { }
}
export const Loading = Template.bind({});
Loading.args = {
isLoading: true
}
export const Customized = Template.bind({});
Customized.args = {
href: "#",
color: 'none',
className: 'bg-red-500 text-white underline font-bold'
}
const ListTemplate: ComponentStory<typeof Badge> = (args) => <div className="flex gap-8">
{Array(4).fill(0).map((_, idx) => <Badge key={idx} {...args} >Badge {idx + 1}</Badge>)}
</div>
export const BadgesList = ListTemplate.bind({})

View File

@@ -0,0 +1,72 @@
import { PropsWithChildren } from "react";
import { IoMdCloseCircle } from "react-icons/io";
import Skeleton from "react-loading-skeleton";
import { wrapLink } from "src/utils/hoc";
import { UnionToObjectKeys } from "src/utils/types/utils";
interface Props {
isLoading?: boolean;
color?: 'primary' | 'gray' | 'none'
size?: "sm" | 'md' | 'lg'
shadow?: 'sm' | 'md' | 'lg' | 'none'
className?: string;
href?: string;
onClick?: () => void
onRemove?: () => void
}
const badgrColor: UnionToObjectKeys<Props, 'color'> = {
none: '',
primary: "bg-primary-600 border-0 text-white",
gray: 'bg-gray-100 text-gray-900',
}
const badgeSize: UnionToObjectKeys<Props, 'size'> = {
sm: "px-8 py-4 text-body6",
md: "px-16 py-8 text-body4",
lg: "px-24 py-12 text-body3"
}
const loadingBadgeSize: UnionToObjectKeys<Props, 'size'> = {
sm: "w-48 h-24 text-body6",
md: "w-64 h-32 text-body4",
lg: "w-64 h-42 text-body3"
}
export default function Badge(
{
color = 'gray',
size = 'md',
className,
href,
shadow = 'sm',
children,
isLoading,
onRemove,
onClick
}
: PropsWithChildren<Props>) {
const classes = `
rounded-48 shadow-${shadow} inline-block relative align-middle
${badgrColor[color]}
${badgeSize[size]}
${className}
${(onClick || href) && 'cursor-pointer'}
`
if (isLoading)
return <Skeleton width="6ch" className={`${loadingBadgeSize[size]} !rounded-48 leading-0`} />
return (
wrapLink(
<span className={classes} onClick={onClick}>
<span>{children}</span>
{onRemove && <IoMdCloseCircle onClick={onRemove} className="ml-12 cursor-pointer" />}
</span>
, href)
)
}

View File

@@ -8,7 +8,7 @@ export default {
} as ComponentMeta<typeof Button>;
const Template: ComponentStory<typeof Button> = (args) => <Button {...args} >Click Me 🔥</Button>;
const Template: ComponentStory<typeof Button> = (args) => <Button {...args} >Click Me !!</Button>;
export const Default = Template.bind({});
@@ -18,17 +18,53 @@ Primary.args = {
color: 'primary'
}
export const Red = Template.bind({});
Red.args = {
color: 'red'
}
export const Gray = Template.bind({});
Gray.args = {
color: 'gray'
}
export const OutlinePrimary = Template.bind({});
OutlinePrimary.args = {
color: 'primary',
variant: 'outline'
}
export const OutlineRed = Template.bind({});
OutlineRed.args = {
color: 'red',
variant: 'outline'
}
export const OutlineGray = Template.bind({});
OutlineGray.args = {
color: 'gray',
variant: 'outline'
}
export const SmallSize = Template.bind({});
SmallSize.args = {
color: 'primary',
size: 'sm'
}
export const MediumSize = Template.bind({});
MediumSize.args = {
color: 'primary',
size: 'md'
}
export const LargeSize = Template.bind({});
LargeSize.args = {
color: 'primary',
size: 'lg'
}
export const FullWidth = Template.bind({});
FullWidth.args = {
fullWidth: true
@@ -37,4 +73,21 @@ FullWidth.args = {
export const Link = Template.bind({});
Link.args = {
href: '#'
}
export const DefaultLoading = Template.bind({});
DefaultLoading.args = {
isLoading: true,
}
export const PrimaryLoading = Template.bind({});
PrimaryLoading.args = {
isLoading: true,
color: 'primary'
}
export const GrayLoading = Template.bind({});
GrayLoading.args = {
isLoading: true,
color: 'gray'
}

View File

@@ -1,32 +1,89 @@
import { ReactNode } from 'react';
import { Link } from 'react-router-dom'
import { ComponentProps, ReactNode } from 'react';
import { wrapLink } from 'src/utils/hoc';
import { UnionToObjectKeys } from 'src/utils/types/utils';
// import Loading from '../Loading/Loading';
interface Props {
color?: 'primary' | 'white' | 'gray'
size?: 'md' | 'lg'
color?: 'primary' | 'red' | 'white' | 'gray' | 'none',
variant?: 'fill' | 'outline'
size?: 'sm' | 'md' | 'lg'
children: ReactNode;
href?: string;
fullWidth?: boolean;
onClick?: () => void;
className?: string
isLoading?: boolean;
disableOnLoading?: boolean;
[rest: string]: any;
}
export default function Button(props: Props) {
let classes = "btn inline-block";
const btnStylesFill: UnionToObjectKeys<Props, 'color'> = {
none: "",
primary: "bg-primary-500 border-0 hover:bg-primary-400 active:bg-primary-600 text-white",
gray: 'bg-gray-100 hover:bg-gray-200 text-gray-900 active:bg-gray-300',
white: 'text-gray-900 bg-gray-25 hover:bg-gray-50',
red: "bg-red-600 border-0 hover:bg-red-500 active:bg-red-700 text-white",
}
if (props.color === 'primary') classes += ' btn-primary';
else if (props.color === 'gray') classes += ' btn-gray';
const btnStylesOutline: UnionToObjectKeys<Props, 'color'> = {
none: "",
primary: "text-primary-600",
gray: 'text-gray-700',
white: 'text-gray-900',
red: "text-red-500",
}
if (props.size === 'md') classes += ' py-12 px-24';
const baseBtnStyles: UnionToObjectKeys<Props, 'variant'> = {
fill: " shadow-sm active:scale-95",
outline: "bg-gray-900 bg-opacity-0 hover:bg-opacity-5 active:bg-opacity-10 border border-gray-200 active:scale-95 "
}
if (props.fullWidth) classes += ' w-full'
// const loadingColor: UnionToObjectKeys<Props, 'color', ComponentProps<typeof Loading>['color']> = {
// none: "white",
// primary: "white",
// gray: 'primary',
// white: 'primary',
// red: "white"
// } as const;
const btnPadding: UnionToObjectKeys<Props, 'size'> = {
sm: "py-8 px-12 text-body5",
md: "py-12 px-24 text-body4",
lg: 'py-12 px-36 text-body4'
}
export default function Button({ color = 'white', variant = 'fill', isLoading, disableOnLoading = true, size = 'md', fullWidth, href, className, onClick, children, ...props }: Props) {
let classes = `
inline-block font-sans rounded-lg font-regular border border-gray-300 hover:cursor-pointer
${baseBtnStyles[variant]}
${btnPadding[size]}
${variant === 'fill' ? btnStylesFill[color] : btnStylesOutline[color]}
${isLoading && disableOnLoading && 'bg-opacity-70 pointer-events-none'}
`;
if (size === 'md') classes += ' py-12 px-24';
if (size === 'lg')
if (fullWidth) classes += ' w-full'
const handleClick = () => {
if (props.onClick) props.onClick();
if (isLoading && disableOnLoading) return;
if (onClick) onClick();
}
return (
props.href ? <Link to={props.href} className={`${classes} ${props.className}`} onClick={handleClick}>{props.children}</Link> : <button className={`${classes} ${props.className}`} onClick={handleClick}>{props.children}</button>
return (
wrapLink(
<button
type='button'
className={`${classes} ${className}`}
onClick={() => handleClick()}
{...props}
>
{/* {isLoading ? <Loading color={loadingColor[color]} /> : children} */}
{children}
</button>
, href)
)
}

View File

@@ -1,5 +1,6 @@
import { useQuery } from '@apollo/client';
import { ALL_CATEGORIES_QUERY, ALL_CATEGORIES_QUERY_RES } from './query';
import Badge from 'src/Components/Badge/Badge'
export default function Categories() {
@@ -9,12 +10,18 @@ export default function Categories() {
}
if (loading)
return null;
if (loading || !data)
return <div className="flex gap-12 flex-wrap">
{Array(5).fill(0).map((_, idx) =>
<Badge key={idx} isLoading></Badge>
)}
</div>
return (
<div className="flex gap-12 flex-wrap">
{data?.allCategories.map(category => <span key={category.id} className="chip hover:cursor-pointer hover:bg-gray-200" onClick={() => handleClick(category.id)}>{category.title}</span>)}
{data?.allCategories.map(category =>
<Badge key={category.id} onClick={() => handleClick(category.id)}>{category.title}</Badge>
)}
</div>
)
}

View File

@@ -0,0 +1,15 @@
import Skeleton from 'react-loading-skeleton'
export default function ProjectCardMiniSkeleton() {
return (
<div className="bg-gray-25 select-none px-16 py-16 flex w-[296px] gap-16 border border-gray-200 rounded-10 items-center" >
<Skeleton width={80} height={80} containerClassName='flex-shrink-0' />
<div className="justify-around items-start min-w-0">
<p className="text-body4 w-full font-bold overflow-ellipsis overflow-hidden whitespace-nowrap"><Skeleton width="15ch" /></p>
<p className="text-body5 text-gray-600 font-light my-[5px]"><Skeleton width="10ch" /></p>
<span className="font-light text-body5"> <Skeleton width="5ch"></Skeleton> </span>
</div>
</div>
);
}

View File

@@ -2,6 +2,7 @@ import { ComponentStory, ComponentMeta } from '@storybook/react';
import mockData from 'src/api/mockData.json'
import ProjectCardMini from './ProjectCardMini';
import ProjectCardMiniSkeleton from './ProjectCardMini.Skeleton';
export default {
@@ -19,3 +20,11 @@ Default.args = {
const SkeletonTemplate: ComponentStory<typeof ProjectCardMiniSkeleton> = (args) => <ProjectCardMiniSkeleton />;
export const LoadingState = SkeletonTemplate.bind({});
LoadingState.args = {
}

View File

@@ -0,0 +1,21 @@
import ProjectCardMiniSkeleton from "../ProjectCardMini/ProjectCardMini.Skeleton";
import Skeleton from "react-loading-skeleton";
export default function ProjectsRowSkeleton() {
return (
<div className='mb-48'>
<h3 className="font-bolder text-body3 mb-24 px-32">
<Skeleton width='10ch' />
</h3>
<div className="p-32 flex gap-20">
{Array(5).fill(0).map((_, idx) => (
<ProjectCardMiniSkeleton key={idx} />
))}
</div>
</div>
)
}

View File

@@ -1,5 +1,7 @@
import ProjectsRow from "../ProjectsRow/ProjectsRow";
import ProjectsRowSkeleton from "../ProjectsRow/ProjectsRow.Skeleton";
import { MdLocalFireDepartment } from "react-icons/md";
import { useQuery } from "@apollo/client";
import { ALL_CATEGORIES_PROJECTS_QUERY, ALL_CATEGORIES_PROJECTS_RES } from "./query";
@@ -9,7 +11,9 @@ export default function ProjectsSection() {
const { data, loading } = useQuery<ALL_CATEGORIES_PROJECTS_RES>(ALL_CATEGORIES_PROJECTS_QUERY);
if (loading || !data) return null;
if (loading || !data) return <div className='mt-32 lg:mt-48'>
{Array(3).fill(0).map((_, idx) => <ProjectsRowSkeleton key={idx} />)}
</div>;
return (
<div className='mt-32 lg:mt-48'>

View File

@@ -0,0 +1,82 @@
import { motion } from 'framer-motion'
import { MdClose, } from 'react-icons/md';
import { ModalCard, modalCardVariants } from 'src/Components/Modals/ModalsContainer/ModalsContainer';
import Skeleton from 'react-loading-skeleton';
import Badge from 'src/Components/Badge/Badge';
import { useAppSelector } from 'src/utils/hooks';
interface Props extends ModalCard {
}
export default function ProjectCardSkeleton({ onClose, direction, ...props }: Props) {
const { isMobileScreen } = useAppSelector(state => ({
isMobileScreen: state.theme.isMobileScreen
}));
return (
<motion.div
custom={direction}
variants={modalCardVariants}
initial='initial'
animate="animate"
exit='exit'
className={`modal-card max-w-[768px] ${props.isPageModal && isMobileScreen && 'rounded-0 w-full min-h-screen'}`}
>
<div className="relative h-[80px] lg:h-[152px]">
<Skeleton height='100%' className='!leading-inherit' />
<button className="w-[48px] h-[48px] bg-white z-10 absolute top-1/2 left-32 -translate-y-1/2 rounded-full hover:bg-gray-200 text-center" onClick={onClose}><MdClose className=' inline-block text-body2 lg:text-body1' /></button>
</div>
<div className="p-24">
<div className="flex gap-24 items-center h-[93px]">
<div className="flex-shrink-0 w-[93px] h-[93px] rounded-md overflow-hidden">
<Skeleton height='100%' />
</div>
<div className='flex flex-col items-start justify-between self-stretch'>
<h3 className="text-h3 font-regular"> <Skeleton width='13ch' /></h3>
<span className="text-blue-400 font-regular text-body4" > <Skeleton width='6ch' /></span>
<div className='flex gap-8'>
<Badge size='sm' isLoading />
<Badge size='sm' isLoading />
</div>
</div>
</div>
<p className="mt-40 text-body4 leading-normal">
<Skeleton width='98%' />
<Skeleton width='90%' />
<Skeleton width='70%' />
<Skeleton width='40%' />
</p>
<div className="mt-40">
<h3 className="text-h5 font-bold mb-16">Screenshots</h3>
<div className="grid grid-cols-2 gap-12 justify-items-center md:gap-24">
<div className="w-full relative pt-[56%]">
<div className="absolute top-0 left-0 w-full h-full object-cover bg-gray-300 rounded-xl"></div>
</div>
<div className="w-full relative pt-[56%]">
<div className="absolute top-0 left-0 w-full h-full object-cover bg-gray-300 rounded-xl"></div>
</div>
<div className="w-full relative pt-[56%]">
<div className="absolute top-0 left-0 w-full h-full object-cover bg-gray-300 rounded-xl"></div>
</div>
<div className="w-full relative pt-[56%]">
<div className="absolute top-0 left-0 w-full h-full object-cover bg-gray-300 rounded-xl"></div>
</div>
</div>
</div>
</div>
</motion.div>
)
}

View File

@@ -1,6 +1,7 @@
import { ComponentStory, ComponentMeta } from '@storybook/react';
import ProjectCard from './ProjectCard';
import ProjectCardSkeleton from './ProjectCard.Skeleton';
import { ModalsDecorator } from '.storybook/helpers'
@@ -15,3 +16,7 @@ const Template: ComponentStory<typeof ProjectCard> = (args) => <ProjectCard {...
export const Default = Template.bind({});
const LoadingTemplate: ComponentStory<typeof ProjectCardSkeleton> = (args) => <ProjectCardSkeleton {...args} />;
export const LoadingState = LoadingTemplate.bind({})

View File

@@ -11,6 +11,8 @@ import Button from 'src/Components/Button/Button';
import { requestProvider } from 'webln';
import { PROJECT_BY_ID_QUERY, PROJECT_BY_ID_RES, PROJECT_BY_ID_VARS } from './query'
import { AiFillThunderbolt } from 'react-icons/ai';
import ProjectCardSkeleton from './ProjectCard.Skeleton'
interface Props extends ModalCard {
projectId: string
@@ -39,7 +41,8 @@ export default function ProjectCard({ onClose, direction, projectId, ...props }:
if (loading || !project) return <></>;
if (loading || !project)
return <ProjectCardSkeleton onClose={onClose} direction={direction} isPageModal={props.isPageModal} />;
const onConnectWallet = async () => {
try {
@@ -83,14 +86,8 @@ export default function ProjectCard({ onClose, direction, projectId, ...props }:
}
return (
<motion.div
custom={direction}
variants={modalCardVariants}
initial='initial'
animate="animate"
exit='exit'
<div
className={`modal-card max-w-[768px] ${props.isPageModal && isMobileScreen && 'rounded-0 w-full min-h-screen'}`}
>
<div className="relative h-[80px] lg:h-[152px]">
<img className="w-full h-full object-cover" src={project.cover_image} alt="" />
@@ -152,6 +149,6 @@ export default function ProjectCard({ onClose, direction, projectId, ...props }:
<Button color='gray' size='md' className="my-16" onClick={onClaim}>Claim 🖐</Button>
</div>
</div>
</motion.div>
</div>
)
}

View File

@@ -1,11 +1,12 @@
import { QueryClient, QueryClientProvider } from 'react-query'
import { BrowserRouter } from 'react-router-dom';
import { Provider } from 'react-redux';
import { store } from '../redux/store';
import 'react-multi-carousel/lib/styles.css';
import "react-loader-spinner/dist/loader/css/react-spinner-loader.css";
import { BrowserRouter } from 'react-router-dom';
import 'react-loading-skeleton/dist/skeleton.css'
import {
ApolloClient,

10
src/utils/hoc.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { Link } from "react-router-dom";
export function wrapLink(Component: JSX.Element, href: string | undefined, className?: string) {
if (!href) return Component;
return <Link to={href} className={className}>
{Component}
</Link>
}

11
src/utils/types/utils.ts Normal file
View File

@@ -0,0 +1,11 @@
export type UnionToObjectKeys<O, Key extends keyof O, Value = string> = { [EE in NonNullable<O[Key]> extends string ? NonNullable<O[Key]> : never]: Value }
export type Id<T> = {} & { [P in keyof T]: T[P] } // flatens out the types to make them more readable can be removed
export type RemoveCommonValues<T, TOmit> = {
[P in keyof T]: TOmit extends Record<P, infer U> ? Exclude<T[P], U> : T[P]
}
export type ValueOf<T> = Id<T[keyof T]>;
export type OmitId<T, Id extends string = 'id'> = Omit<T, Id>

View File

@@ -123,8 +123,13 @@ module.exports = {
16: "16px",
20: "20px",
24: "24px",
48: "48px",
full: "50%",
},
lineHeight: {
'inherit': "inherit",
0: '0'
},
outline: {
primary: ["2px solid #7B61FF", "1px"],
},