diff --git a/.storybook/main.js b/.storybook/main.js index c41d607..1c28e9f 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -3,11 +3,13 @@ const setRootPath = (_path) => path.resolve(process.cwd(), _path); module.exports = { stories: ['../src/**/*.stories.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], + staticDirs: ['../public'], addons: [ '@storybook/addon-links', '@storybook/addon-essentials', '@storybook/addon-interactions', '@storybook/addon-a11y', + 'storybook-addon-next-router', ], framework: '@storybook/react', diff --git a/.storybook/preview.js b/.storybook/preview.js index f478437..91af775 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -1,19 +1,27 @@ import ko from 'axe-core/locales/ko.json'; import React from 'react'; +import { RouterContext } from 'next/dist/shared/lib/router-context'; import { GlobalStyle } from 'styles/GlobalStyle'; import { ThemeProvider } from '@emotion/react'; import { theme } from 'theme/theme'; +import * as NextImage from 'next/image'; +import { StoreProvider } from 'store'; export const decorators = [ (Story) => ( - - + + + + ), ]; export const parameters = { + nextRouter: { + Provider: RouterContext.Provider, + }, a11y: { config: { locale: ko }, }, @@ -25,3 +33,17 @@ export const parameters = { }, }, }; + +const OriginalNextImage = NextImage.default; + +Object.defineProperty(NextImage, 'default', { + configurable: true, + value: (props) => ( + + ), +}); diff --git a/package.json b/package.json index 7a82f72..264b1b5 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "firebase-tools": "^10.3.0", "prettier": "^2.5.1", "redux-logger": "^3.0.6", + "storybook-addon-next-router": "^3.1.1", "typescript": "4.6.2" } } diff --git a/public/images/default.jpg b/public/images/default.jpg new file mode 100644 index 0000000..7c76e09 Binary files /dev/null and b/public/images/default.jpg differ diff --git a/public/images/food-image-temp.jpg b/public/images/food-image-temp.jpg new file mode 100644 index 0000000..6969f2c Binary files /dev/null and b/public/images/food-image-temp.jpg differ diff --git a/public/images/no-image.jpg b/public/images/no-image.jpg new file mode 100644 index 0000000..9f3cbc1 Binary files /dev/null and b/public/images/no-image.jpg differ diff --git a/src/assets/images/default.jpg b/src/assets/images/default.jpg new file mode 100644 index 0000000..7c76e09 Binary files /dev/null and b/src/assets/images/default.jpg differ diff --git a/src/assets/images/food-image-temp.jpg b/src/assets/images/food-image-temp.jpg new file mode 100644 index 0000000..6969f2c Binary files /dev/null and b/src/assets/images/food-image-temp.jpg differ diff --git a/src/assets/images/no-image.jpg b/src/assets/images/no-image.jpg new file mode 100644 index 0000000..9f3cbc1 Binary files /dev/null and b/src/assets/images/no-image.jpg differ diff --git a/src/components/Card/Card.stories.tsx b/src/components/Card/Card.stories.tsx new file mode 100644 index 0000000..6138256 --- /dev/null +++ b/src/components/Card/Card.stories.tsx @@ -0,0 +1,26 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { Card } from './Card'; + +export default { + title: 'Card', + component: Card, + args: { + id: 1, + type: 'wide', + background: 'white', + hasSummary: true, + headingPosition: 'bottomLeft', + title: 'hi', + }, + parameters: { + nextRouter: { + query: { + id: 1, + }, + }, + }, +} as ComponentMeta; + +const Template: ComponentStory = (args) => ; + +export const Default = Template.bind({}); diff --git a/src/components/Card/Card.styled.tsx b/src/components/Card/Card.styled.tsx new file mode 100644 index 0000000..31fab7a --- /dev/null +++ b/src/components/Card/Card.styled.tsx @@ -0,0 +1,161 @@ +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; +import { media, pxToRem } from 'utils'; +import { CardContainerProps, CardFigcaptionProps, CardFigureImgProps } from './Card.types'; + +const inlineBlock = css` + text-align: center; + width: 100%; + ${media.desktop} { + display: inline-block; + } +`; + +const typeCss = { + wide: css` + width: 100%; + height: 50vw; + object-fit: cover; + + ${media.desktop} { + width: 100%; + height: 250px; + } + `, + square: css` + width: 100%; + + ${media.mobile} { + height: 50vw; + } + ${media.desktop} { + height: rem(200px); + @media (max-width: 1547px) { + height: 20vw; + } + + @media (max-width: 1100px) { + height: 25vw; + } + } + object-fit: cover; + `, + + smallSquare: css` + width: 100%; + aspect-ratio: 1 / 1; + object-fit: cover; + ${media.mobile} { + aspect-ratio: 16 / 9; + } + + @media (max-width: 1056px) { + aspect-ratio: 16 / 9; + } + `, +}; + +const headingPositionCss = { + bottomLeft: css` + font-size: ${pxToRem(20)}; + left: 0; + `, + bottomCenter: css` + font-size: ${pxToRem(20)}; + text-align: center; + `, + topLeft: css` + font-size: ${pxToRem(24)}; + color: orange; + order: -1; + `, +}; + +export const CardLink = styled.a` + width: 100%; + border: none; + background-color: transparent; + text-align: left; + ${media.mobile} { + width: 100%; + } +`; + +export const CardContainer = styled.div` + height: 100%; + display: flex; + flex-direction: column; + padding: ${pxToRem(16)}; + position: relative; + cursor: pointer; + + background: ${({ $background }) => ($background === 'white' ? 'white' : 'none')}; + box-shadow: ${({ $background }) => $background === 'white' && '0px 4px 4px rgba(0, 0, 0, 0.25);'}; + + ${({ $type }) => $type === 'square' && inlineBlock} +`; + +export const CardFigureImgContainer = styled.div` + ${({ $type }) => typeCss[$type]} +`; + +export const CardFigcaption = styled.figcaption` + font-size: ${pxToRem(18)}; + font-weight: bold; + padding: ${pxToRem(16)} 0; + width: 100%; + + ${({ $headingPosition }) => headingPositionCss[$headingPosition]} +`; + +export const CardSummary = styled.div` + line-height: ${pxToRem(24)}; + ${media.desktop} { + display: -webkit-box; + word-wrap: break-word; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + &:after { + content: ''; + color: black; + position: absolute; + display: block; + width: 100%; + height: 30%; + bottom: 0; + left: 0; + background: linear-gradient(to top, #fff 50%, transparent); + } + } + ${media.mobile} { + display: -webkit-box; + -webkit-line-clamp: 1; + word-wrap: break-word; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + } +`; + +export const CardSummaryText = styled.p` + margin-top: 0; + margin-bottom: ${pxToRem(8)}; +`; + +export const CardButton = styled.button` + ${media.mobile} { + display: none; + } + + padding: ${pxToRem(8)} 0; + font-size: ${pxToRem(24)}; + z-index: 1; + margin: 0; + + display: flex; + gap: 10px; + border: none; + background: none; + justify-content: center; +`; diff --git a/src/components/Card/Card.tsx b/src/components/Card/Card.tsx new file mode 100644 index 0000000..172d09b --- /dev/null +++ b/src/components/Card/Card.tsx @@ -0,0 +1,58 @@ +import Image from 'next/image'; +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import { useState } from 'react'; +import { HiCursorClick } from 'react-icons/hi'; +import { excludeTags } from 'utils'; +import { + CardButton, + CardContainer, + CardFigcaption, + CardFigureImgContainer, + CardLink, + CardSummary, + CardSummaryText, +} from './Card.styled'; +import { CardProps } from './Card.types'; + +export const Card = ({ + id = 0, + type, + background, + hasSummary, + headingPosition, + // image, + imgSrc = '/images/no-image.jpg', + title, + summary = '', +}: CardProps): JSX.Element => { + return ( + + + +
+ + + + {title} +
+ {hasSummary && ( + <> + + {excludeTags(summary) + .split('. ') + .map((text, index, texts) => ( + {text + (index < texts.length - 1 ? '.' : '')} + ))} + + + Click for more + + + + )} +
+
+ + ); +}; diff --git a/src/components/Card/Card.types.ts b/src/components/Card/Card.types.ts new file mode 100644 index 0000000..ae87b28 --- /dev/null +++ b/src/components/Card/Card.types.ts @@ -0,0 +1,27 @@ +type CardType = 'wide' | 'square' | 'smallSquare'; +type CardBackgroundType = 'white' | 'none'; +type CardHeadingPositionType = 'bottomLeft' | 'bottomCenter' | 'topLeft'; + +export interface CardProps { + id: string | number; + type: CardType; + background: CardBackgroundType; + hasSummary: boolean; + headingPosition: CardHeadingPositionType; + imgSrc: string; + title: string; + summary?: string; +} + +export interface CardContainerProps { + $type: CardType; + $background: CardBackgroundType; +} + +export interface CardFigureImgProps { + $type: CardType; +} + +export interface CardFigcaptionProps { + $headingPosition: CardHeadingPositionType; +} diff --git a/src/components/CookingInfo/CookingInfo.tsx b/src/components/CookingInfo/CookingInfo.tsx index 3b59083..bc014b6 100644 --- a/src/components/CookingInfo/CookingInfo.tsx +++ b/src/components/CookingInfo/CookingInfo.tsx @@ -9,7 +9,7 @@ export const CookingInfo = ({ time = 0, count = 0 }: CookingInfoProps): JSX.Elem Cooking Time - {time} miuntes + {time} minutes
diff --git a/src/components/HotRecipes/HotRecipes.stories.tsx b/src/components/HotRecipes/HotRecipes.stories.tsx new file mode 100644 index 0000000..c599e91 --- /dev/null +++ b/src/components/HotRecipes/HotRecipes.stories.tsx @@ -0,0 +1,11 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { HotRecipes } from './HotRecipes'; + +export default { + title: 'HotRecipes', + component: HotRecipes, +} as ComponentMeta; + +const Template: ComponentStory = () => ; + +export const Default = Template.bind({}); diff --git a/src/components/HotRecipes/HotRecipes.styled.tsx b/src/components/HotRecipes/HotRecipes.styled.tsx new file mode 100644 index 0000000..d6741ec --- /dev/null +++ b/src/components/HotRecipes/HotRecipes.styled.tsx @@ -0,0 +1,55 @@ +import styled from '@emotion/styled'; +import { Heading } from '..'; +import { media, pxToRem } from 'utils'; + +export const HotRecipesSection = styled.section` + ${media.desktop} { + flex-grow: 2; + padding-top: ${pxToRem(20)}; + height: 80vh; + } +`; + +export const HotRecipesHeader = styled(Heading)` + ${media.mobile} { + margin-top: ${pxToRem(40)}; + } + + ${media.desktop} { + padding-left: ${pxToRem(16)}; + } + + @media (min-width: 1546px) { + padding-left: ${pxToRem(28)}; + } +`; + +export const HotRecipesCardList = styled.ul` + display: flex; + flex-direction: column; + ${media.mobile} { + margin: 0; + } + ${media.desktop} { + flex-direction: row; + flex-wrap: wrap; + justify-content: center; + height: 99%; + overflow: auto; + } +`; + +export const HotRecipesCardItem = styled.li` + text-align: center; + width: ${pxToRem(290)}; + + @media (max-width: 1547px) { + width: 50%; + text-align: center; + } + + @media (max-width: 1100px) { + width: 100%; + text-align: center; + } +`; diff --git a/src/components/HotRecipes/HotRecipes.tsx b/src/components/HotRecipes/HotRecipes.tsx new file mode 100644 index 0000000..031e998 --- /dev/null +++ b/src/components/HotRecipes/HotRecipes.tsx @@ -0,0 +1,37 @@ +import { SkeletonCard, Card } from '..'; +import { useHotRecipes } from 'hooks'; +import { HotRecipesCardItem, HotRecipesCardList, HotRecipesHeader, HotRecipesSection } from './HotRecipes.styled'; + +export const HotRecipes = () => { + const { hotRecipes, loading } = useHotRecipes(); + + //TODO: customAPi 완료후 여기에 타입 달아주기 + const renderCards = ({ recipeId, image, title }) => { + if (loading) { + return ; + } else { + return ( + + ); + } + }; + + return ( + + Hot Recipes + + {hotRecipes.map((recipe, idx) => { + return {renderCards(recipe)}; + })} + + + ); +}; diff --git a/src/components/RandomRecipe/RandomRecipe.stories.tsx b/src/components/RandomRecipe/RandomRecipe.stories.tsx new file mode 100644 index 0000000..d66ec47 --- /dev/null +++ b/src/components/RandomRecipe/RandomRecipe.stories.tsx @@ -0,0 +1,11 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { RandomRecipe } from './RandomRecipe'; + +export default { + title: 'RandomRecipe', + component: RandomRecipe, +} as ComponentMeta; + +const Template: ComponentStory = () => ; + +export const Default = Template.bind({}); diff --git a/src/components/RandomRecipe/RandomRecipe.styled.tsx b/src/components/RandomRecipe/RandomRecipe.styled.tsx new file mode 100644 index 0000000..8dfad1d --- /dev/null +++ b/src/components/RandomRecipe/RandomRecipe.styled.tsx @@ -0,0 +1,51 @@ +import styled from '@emotion/styled'; +import { Button } from '../'; +import { media, pxToRem } from 'utils'; +import { GiPerspectiveDiceSixFacesRandom } from 'react-icons/gi'; + +export const RandomRecipeSection = styled.section` + flex-grow: 1; + position: relative; + font-family: inherit; + + ${media.desktop} { + height: 80vh; + max-width: 30%; + min-width: ${pxToRem(512)}; + padding-top: ${pxToRem(20)}; + margin: 0 40px; + } +`; + +export const RandomRecipeButton = styled(Button)` + display: flex; + align-items: center; + gap: 10px; + position: absolute; + top: 0; + right: 0; + margin: ${pxToRem(16)}; + + ${media.mobile} { + margin: ${pxToRem(8)} 0 ${pxToRem(16)} ${pxToRem(16)}; + font-size: ${pxToRem(12)}; + padding: ${pxToRem(1)} ${pxToRem(6)}; + } + + ${media.desktop} { + margin: ${pxToRem(36)} 0 ${pxToRem(36)} ${pxToRem(36)}; + padding: ${pxToRem(6)} ${pxToRem(18)}; + } +`; + +export const RandomDiceIcon = styled(GiPerspectiveDiceSixFacesRandom)` + font-size: 25px; +`; + +export const RandomRecipeCardWrapper = styled.div` + margin-top: ${pxToRem(16)}; + + ${media.desktop} { + height: 70vh; + } +`; diff --git a/src/components/RandomRecipe/RandomRecipe.tsx b/src/components/RandomRecipe/RandomRecipe.tsx new file mode 100644 index 0000000..e1f4a98 --- /dev/null +++ b/src/components/RandomRecipe/RandomRecipe.tsx @@ -0,0 +1,47 @@ +import { useRandomRecipe } from 'hooks'; +import { RandomRecipe as RandomRecipeType } from 'store/services/types/queries'; +import { Heading, SkeletonCard, Card } from '..'; +import { + RandomDiceIcon, + RandomRecipeButton, + RandomRecipeCardWrapper, + RandomRecipeSection, +} from './RandomRecipe.styled'; + +export const RandomRecipe = (): JSX.Element => { + const { recipe, error, isLoading, handleClick } = useRandomRecipe(); + + console.log(recipe); + console.log(error); + + const renderCard = () => { + if (isLoading) { + return ; + } else { + const { id, title, summary, image } = recipe as RandomRecipeType; + return ( + + ); + } + }; + + return ( + + Random Recipe + + + REROLL + + {renderCard()} + + ); +}; diff --git a/src/components/SkeletonCard/SkeletonCard.styled.tsx b/src/components/SkeletonCard/SkeletonCard.styled.tsx index dd6dfb1..80bd7f8 100644 --- a/src/components/SkeletonCard/SkeletonCard.styled.tsx +++ b/src/components/SkeletonCard/SkeletonCard.styled.tsx @@ -27,22 +27,23 @@ const inlineBlock = css` justify-content: center; `; -const squareType = css` - width: ${pxToRem(200)}; - height: ${pxToRem(200)}; - background: gray; -`; - -const wideType = css` - width: 100%; - height: 50vw; - background: gray; - - ${media.desktop} { +const typeCss = { + square: css` + width: ${pxToRem(200)}; + height: ${pxToRem(200)}; + background: gray; + `, + wide: css` width: 100%; - height: 250px; - } -`; + height: 50vw; + background: gray; + + ${media.desktop} { + width: 100%; + height: 250px; + } + `, +}; const hasSummaryTrue = css` display: block; @@ -60,19 +61,20 @@ const hasSummaryFalse = css` display: none; `; -const bottomLeft = css` - background: gray; - left: 0; -`; -const bottomCenter = css` - background: gray; - align-self: center; -`; - -const topLeft = css` - background: gray; - order: -1; -`; +const headingPositionCss = { + bottomLeft: css` + background: gray; + left: 0; + `, + bottomCenter: css` + background: gray; + align-self: center; + `, + topLeft: css` + background: gray; + order: -1; + `, +}; export const SkeletonContainer = styled.div` ${(props) => props.$type === 'square' && inlineBlock} @@ -91,7 +93,7 @@ export const SkeletonCardWrapper = styled.div` `; export const SkeletonImage = styled.div` - ${(props) => (props.$type === 'square' ? squareType : wideType)} + ${({ $type }) => typeCss[$type]} overflow: hidden; position: relative; @@ -101,12 +103,7 @@ export const SkeletonImage = styled.div` `; export const SkeletonTitle = styled.div` - ${(props) => - props.$headingPosition === 'topLeft' - ? topLeft - : props.$headingPosition === 'bottomCenter' - ? bottomCenter - : bottomLeft} + ${({ $headingPosition }) => headingPositionCss[$headingPosition]} margin: ${pxToRem(16)} 0 !important; width: 70%; @@ -122,5 +119,5 @@ export const SkeletonTitle = styled.div` `; export const SkeletonSummary = styled.div` - ${(props) => (props.$hasSummary ? hasSummaryTrue : hasSummaryFalse)} + ${({ $hasSummary }) => ($hasSummary ? hasSummaryTrue : hasSummaryFalse)} `; diff --git a/src/components/index.ts b/src/components/index.ts index e05baaf..46add14 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -11,3 +11,6 @@ export * from './Header/Header'; export * from './EmptyPage/EmptyPage'; export * from './SearchForm/SearchForm'; export * from './SkeletonCard/SkeletonCard'; +export * from './Card/Card'; +export * from './RandomRecipe/RandomRecipe'; +export * from './HotRecipes/HotRecipes'; diff --git a/src/hooks/.keep b/src/hooks/.keep deleted file mode 100644 index e69de29..0000000 diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 0000000..3604ec7 --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1,3 @@ +export * from './usePageNum'; +export * from './useRandomRecipe'; +export * from './useHotRecipes'; diff --git a/src/hooks/useHotRecipes.ts b/src/hooks/useHotRecipes.ts new file mode 100644 index 0000000..7e1d9c8 --- /dev/null +++ b/src/hooks/useHotRecipes.ts @@ -0,0 +1,18 @@ +import { useEffect, useState } from 'react'; + +export const useHotRecipes = () => { + const [hotRecipes, setHotRecipes] = useState([]); + const [loading, setLoading] = useState(true); + + // 이 부분 custom API 완료후 적용! + // useEffect(() => { + // (async () => { + // setHotRecipes(await getHotRecipes()); + // })(); + // setTimeout(() => { + // setLoading(false); + // }, 1200); + // }, []); + + return { hotRecipes, loading }; +}; diff --git a/src/hooks/useRandomRecipe.ts b/src/hooks/useRandomRecipe.ts new file mode 100644 index 0000000..b1ea182 --- /dev/null +++ b/src/hooks/useRandomRecipe.ts @@ -0,0 +1,36 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useGetRandomRecipeQuery } from 'store/services'; +import _ from 'lodash'; +import { RandomRecipe } from 'store/services/types/queries'; + +export const useRandomRecipe = () => { + const [savedRecipe, setSavedRecipe] = useState({}); + const [recipe, setRecipe] = useState({}); + + const { data, error, isLoading } = useGetRandomRecipeQuery(2); + + useEffect(() => { + if (data) { + const { recipes } = data; + setRecipe(recipes[0]); + setSavedRecipe(recipes[1]); + } + }, []); + + const getRecipe = useCallback(() => { + setRecipe(savedRecipe); + const { data } = useGetRandomRecipeQuery(1); + if (data) { + const { recipes } = data; + setSavedRecipe(recipes[0]); + } + }, []); + + const handleClick = () => { + _.throttle(() => { + getRecipe(); + }, 300); + }; + + return { recipe, error, isLoading, handleClick }; +}; diff --git a/src/pages/index.tsx b/src/pages/index.tsx index ce52bf1..4a874c8 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -4,8 +4,12 @@ import Head from 'next/head'; import Image from 'next/image'; const Home: NextPage = () => { - const { data, error, isLoading } = useGetRandomRecipeQuery(1); - console.log(data); + const { + data: { recipes }, + error, + isLoading, + } = useGetRandomRecipeQuery(1); + console.log(recipes); console.log(error); console.log(isLoading); diff --git a/src/store/services/index.ts b/src/store/services/index.ts index 06de4c9..dd05900 100644 --- a/src/store/services/index.ts +++ b/src/store/services/index.ts @@ -16,7 +16,6 @@ export const twoSpoonApi = createApi({ endpoints: (builder) => ({ getRandomRecipe: builder.query({ query: (number = 1) => `recipes/random?number=${number}`, - // transformResponse: (response: { data: RandomRecipeQuery }) => response.data, }), }), }); diff --git a/src/utils/dom.ts b/src/utils/dom.ts index 97ac82b..53e994e 100644 --- a/src/utils/dom.ts +++ b/src/utils/dom.ts @@ -1,4 +1,6 @@ export const $ = (selector: string) => { - const element = document.querySelector(selector); - return element as T; + if (typeof window !== 'undefined') { + const element = document.querySelector(selector); + return element as T; + } }; diff --git a/src/utils/index.ts b/src/utils/index.ts index 96ee6ae..b601cfd 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,3 +1,4 @@ export * from './style'; export * from './dom'; +export * from './misc'; export * from './tabbable'; diff --git a/src/utils/misc.ts b/src/utils/misc.ts new file mode 100644 index 0000000..2093923 --- /dev/null +++ b/src/utils/misc.ts @@ -0,0 +1 @@ +export const excludeTags = (content: string) => content.replace(/<[^>]*>/g, ''); diff --git a/yarn.lock b/yarn.lock index dd675ba..29d45e3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12661,6 +12661,13 @@ store2@^2.12.0: resolved "https://registry.yarnpkg.com/store2/-/store2-2.13.2.tgz#01ad8802ca5b445b9c316b55e72645c13a3cd7e3" integrity sha512-CMtO2Uneg3SAz/d6fZ/6qbqqQHi2ynq6/KzMD/26gTkiEShCcpqFfTHgOxsE0egAq6SX3FmN4CeSqn8BzXQkJg== +storybook-addon-next-router@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/storybook-addon-next-router/-/storybook-addon-next-router-3.1.1.tgz#46623ca36b450745c3517f5cdc4bf30fa4a4a930" + integrity sha512-Z14dED37vNXkN7+VY80HhF9itGReWoBAlKREHEk2By/dW7zSSqcSyXYV4bDMXIMAFYHMaA1svcBC1idVG8FhAw== + dependencies: + tslib "^2.3.0" + stream-browserify@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/stream-browserify/-/stream-browserify-2.0.2.tgz#87521d38a44aa7ee91ce1cd2a47df0cb49dd660b"