diff --git a/package.json b/package.json index 9c2c2f7..a89564b 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ "dependencies": { "@emotion/react": "^11.8.2", "@emotion/styled": "^11.8.1", - "@svgr/webpack": "^5.5.0", "@reduxjs/toolkit": "^1.8.0", + "@svgr/webpack": "^5.5.0", "@types/react-redux": "^7.1.23", "@types/yup": "^0.29.13", "emotion-normalize": "^11.0.1", @@ -23,6 +23,7 @@ "formik": "^2.2.9", "next": "12.1.0", "next-redux-wrapper": "^7.0.5", + "polished": "^4.1.4", "react": "17.0.2", "react-dom": "17.0.2", "react-icons": "^4.3.1", diff --git a/src/assets/icons/logo.svg b/src/assets/icons/logo.svg new file mode 100644 index 0000000..72c12eb --- /dev/null +++ b/src/assets/icons/logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/components/Button/Button.types.ts b/src/components/Button/Button.types.ts index a513ea2..9867044 100644 --- a/src/components/Button/Button.types.ts +++ b/src/components/Button/Button.types.ts @@ -10,6 +10,8 @@ export interface StyledButtonProps { color: Color; style?: React.CSSProperties; className?: string; + onClick?: () => void; + title?: string; } export interface ButtonProps extends StyledButtonProps { @@ -20,9 +22,10 @@ export interface ButtonProps extends StyledButtonProps { export interface StyledIconButtonProps { type: Type; ariaLabel: string; - circle: boolean; + circle?: boolean; color: Color; size: Size; + onClick?: () => void; } export interface IconButtonProps extends StyledIconButtonProps { diff --git a/src/components/Header/Header.styled.tsx b/src/components/Header/Header.styled.tsx new file mode 100644 index 0000000..417d864 --- /dev/null +++ b/src/components/Header/Header.styled.tsx @@ -0,0 +1,37 @@ +import styled from '@emotion/styled'; +import { IconButton } from 'components'; + +const headerHeight = 70; + +export const StyledHeader = styled.header` + background-color: ${({ theme }) => theme.color.white}; + box-shadow: 0 4px 10px rgba(0 0 0 / 10%); + padding: 0 10px 0 20px; + position: fixed; + top: 0; + left: 0; + right: 0; + transition: top 0.2s ease-in-out; + z-index: 10; + + &.hide { + top: ${-1 * headerHeight}px; + } +`; + +export const StyledDiv = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + max-width: 1500px; + margin: 0 auto; + height: ${headerHeight}px; +`; + +export const StyledIconButton = styled(IconButton)` + position: fixed; + right: 20px; + bottom: 20px; + cursor: pointer; + box-shadow: 1px 4px 9px rgb(0 0 0 / 30%); +`; diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx index 97a80da..96f09c3 100644 --- a/src/components/Header/Header.tsx +++ b/src/components/Header/Header.tsx @@ -1,3 +1,102 @@ +import { SearchForm, Menu, Button, Logo } from 'components'; +import { useState, useEffect, useRef } from 'react'; +// import { useAuthLoading, useAuthUser } from '../../contexts/AuthContext'; +import lodash from 'lodash'; +import { createPortal } from 'react-dom'; +import { StyledHeader, StyledDiv, StyledIconButton } from './Header.styled'; + export const Header = (): JSX.Element => { - return
Header comes here
; + // const authLoading = useAuthLoading(); + // const authUser = useAuthUser(); + // const [showDialog, setShowDialog] = useState(false); + + const [tempAuth, setTempAuth] = useState(false); + const [hideHeader, setHideHeader] = useState(false); + const [showScrollToTop, setShowScrollToTop] = useState(false); + const oldScrollTop = useRef(0); + + /* + const handleOpenDialog = () => { + setShowDialog(true); + }; + + const handleCloseDialog = () => { + setShowDialog(false); + }; + */ + + const handleFocus = () => { + setHideHeader(false); + }; + + const handleBlur = () => { + setHideHeader(window.pageYOffset > 70); + }; + + const controlHeader = lodash.throttle(() => { + const currentScrollTop = window.pageYOffset; + setHideHeader(currentScrollTop > 70 && currentScrollTop > oldScrollTop.current); + oldScrollTop.current = currentScrollTop; + }, 300); + + const controlScrollToTop = lodash.debounce(() => { + const currentScrollTop = window.pageYOffset; + setShowScrollToTop(currentScrollTop > 500); + }, 300); + + useEffect(() => { + document.addEventListener('scroll', controlHeader); + document.addEventListener('scroll', controlScrollToTop); + return () => { + document.removeEventListener('scroll', controlHeader); + document.removeEventListener('scroll', controlScrollToTop); + }; + }, []); + + return ( + + + + + {tempAuth ? ( + + ) : ( + <> + + {/* */} + + )} + {showScrollToTop && + createPortal( + { + window.scroll({ + top: 0, + left: 0, + behavior: 'smooth', + }); + }} + />, + document.getElementById('__next')!, + )} + + + ); }; diff --git a/src/components/Logo/Logo.styled.tsx b/src/components/Logo/Logo.styled.tsx index 46bafb7..550824b 100644 --- a/src/components/Logo/Logo.styled.tsx +++ b/src/components/Logo/Logo.styled.tsx @@ -1,6 +1,6 @@ import styled from '@emotion/styled'; import { pxToRem, media } from 'utils'; -import { LogoIcon } from './LogoIcon'; +import LogoIcon from 'assets/icons/logo.svg'; export const StyledA = styled.a` display: flex; diff --git a/src/components/Logo/LogoIcon.tsx b/src/components/Logo/LogoIcon.tsx deleted file mode 100644 index 5f61fc6..0000000 --- a/src/components/Logo/LogoIcon.tsx +++ /dev/null @@ -1,13 +0,0 @@ -export const LogoIcon = () => { - return ( - - - - - ); -}; diff --git a/src/components/Menu/Menu.stories.tsx b/src/components/Menu/Menu.stories.tsx new file mode 100644 index 0000000..fd238f5 --- /dev/null +++ b/src/components/Menu/Menu.stories.tsx @@ -0,0 +1,18 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { Menu } from './Menu'; + +export default { + title: 'Menu', + component: Menu, +} as ComponentMeta; + +const Template: ComponentStory = () => ; + +export const Default = Template.bind({}); +Default.decorators = [ + (Story) => ( +
+ +
+ ), +]; diff --git a/src/components/Menu/Menu.styled.tsx b/src/components/Menu/Menu.styled.tsx new file mode 100644 index 0000000..86892ed --- /dev/null +++ b/src/components/Menu/Menu.styled.tsx @@ -0,0 +1,51 @@ +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; +import { lighten } from 'polished'; + +export const StyledNav = styled.nav` + position: relative; + width: fit-content; +`; + +export const StyledUl = styled.ul` + position: absolute; + top: 60px; + right: 0; + background-color: ${({ theme }) => theme.color.menuBg}; + color: white; + padding: 5px 0; + white-space: nowrap; + z-index: 10; + + & ::before { + content: ''; + position: absolute; + right: 20px; + top: -9px; + width: 0; + height: 0; + border-style: solid; + border-width: 0 5px 10px 5px; + border-color: transparent transparent ${({ theme }) => theme.color.menuBg} transparent; + } +`; + +export const StyledLi = styled.li` + text-align: center; + + & > button, + & > a { + width: 100%; + display: block; + padding: 15px 25px; + } + + & :hover { + ${({ theme }) => { + const menuBg = theme.color.menuBg; + return css` + background-color: ${lighten(0.2, menuBg)}; + `; + }} + } +`; diff --git a/src/components/Menu/Menu.tsx b/src/components/Menu/Menu.tsx new file mode 100644 index 0000000..6aa699d --- /dev/null +++ b/src/components/Menu/Menu.tsx @@ -0,0 +1,65 @@ +import { useState, useEffect } from 'react'; +import { StyledNav, StyledUl, StyledLi } from './Menu.styled'; +import { useRouter } from 'next/router'; +import { IconButton, Button } from 'components'; +import Link from 'next/link'; +import { logOut } from 'api/requestAuth'; + +export const Menu = () => { + const [isOpen, setIsOpen] = useState(false); + + const handleClick = () => { + setIsOpen(!isOpen); + }; + + const handleBlur = (e: React.FocusEvent) => { + if (!e.currentTarget.contains(e.relatedTarget)) { + setIsOpen(false); + } + }; + + const router = useRouter(); + + /* + TODO: 스토리북에서 next.js 설정 후 주석 해제 + useEffect(() => { + const handleRouteChange = () => { + setIsOpen(false); + }; + + router.events.on('routeChangeStart', handleRouteChange); + + return () => { + router.events.off('routeChangeStart', handleRouteChange); + }; + }, []); + */ + + return ( + + + {isOpen && ( + + + My Recipes + + + + + + )} + + ); +}; diff --git a/src/components/SearchForm/SearchForm.stories.tsx b/src/components/SearchForm/SearchForm.stories.tsx new file mode 100644 index 0000000..86c20eb --- /dev/null +++ b/src/components/SearchForm/SearchForm.stories.tsx @@ -0,0 +1,11 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { SearchForm } from './SearchForm'; + +export default { + title: 'SearchForm', + component: SearchForm, +} as ComponentMeta; + +const Template: ComponentStory = () => ; + +export const Default = Template.bind({}); diff --git a/src/components/SearchForm/SearchForm.styled.tsx b/src/components/SearchForm/SearchForm.styled.tsx new file mode 100644 index 0000000..8dfc134 --- /dev/null +++ b/src/components/SearchForm/SearchForm.styled.tsx @@ -0,0 +1,28 @@ +import styled from '@emotion/styled'; +import { media } from 'utils'; +import { IconButton } from 'components'; + +export const StyledForm = styled.form` + position: relative; + width: fit-content; +`; + +export const StyledInput = styled.input` + width: 40vw; + background: ${({ theme }) => theme.color.searchGray}; + border: none; + border-radius: 30px; + padding: 10px 38px 10px 18px; + line-height: 1; + + ${media.mobile} { + width: 50vw; + } +`; + +export const StyledIconButton = styled(IconButton)` + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); +`; diff --git a/src/components/SearchForm/SearchForm.tsx b/src/components/SearchForm/SearchForm.tsx new file mode 100644 index 0000000..e9bce68 --- /dev/null +++ b/src/components/SearchForm/SearchForm.tsx @@ -0,0 +1,57 @@ +import { useState, useEffect } from 'react'; +import { StyledForm, StyledInput, StyledIconButton } from './SearchForm.styled'; +import { useRouter } from 'next/router'; + +export const SearchForm = () => { + const [keyword, setKeyword] = useState(''); + + const router = useRouter(); + + /* + TODO: 스토리북에서 next.js 설정 후 주석 해제 + useEffect(() => { + const handleRouteChange = () => { + setKeyword(''); + }; + + router.events.on('routeChangeStart', handleRouteChange); + + return () => { + router.events.off('routeChangeStart', handleRouteChange); + }; + }, []); + */ + + const handleChange = (e: React.ChangeEvent) => { + setKeyword(e.target.value); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setKeyword(''); + if (keyword.trim()) { + router.push(`/search/${keyword}`); + } else { + alert('Please enter the search word.'); + } + }; + + return ( + + + + + 검색 + + + ); +}; diff --git a/src/components/index.ts b/src/components/index.ts index d337ac0..7843082 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,7 +1,12 @@ +export * from './Badge/Badge'; +export * from './Button/Button'; +export * from './Button/IconButton'; export * from './CookingInfo/CookingInfo'; export * from './Logo/Logo'; export * from './Loading/Loading'; export * from './Layout/Layout'; +export * from './Menu/Menu'; export * from './Header/Header'; export * from './EmptyPage/EmptyPage'; +export * from './SearchForm/SearchForm'; export * from './SkeletonCard/SkeletonCard'; diff --git a/src/pages/404.tsx b/src/pages/404.tsx index 2d925b4..bb2d440 100644 --- a/src/pages/404.tsx +++ b/src/pages/404.tsx @@ -1,8 +1,16 @@ import { EmptyPage } from 'components'; import Link from 'next/link'; import { NextPage } from 'next'; +import { useEffect } from 'react'; +import { useRouter } from 'next/router'; const NotFound: NextPage = () => { + const router = useRouter(); + + useEffect(() => { + router.push('/404', undefined, { shallow: true }); + }, []); + return (

Page Not Found

diff --git a/yarn.lock b/yarn.lock index 110512b..dd675ba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1082,7 +1082,7 @@ core-js-pure "^3.20.2" regenerator-runtime "^0.13.4" -"@babel/runtime@^7.0.0", "@babel/runtime@^7.10.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.10", "@babel/runtime@^7.14.8", "@babel/runtime@^7.15.4", "@babel/runtime@^7.16.3", "@babel/runtime@^7.16.7", "@babel/runtime@^7.3.1", "@babel/runtime@^7.5.0", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2": +"@babel/runtime@^7.0.0", "@babel/runtime@^7.10.2", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.10", "@babel/runtime@^7.14.8", "@babel/runtime@^7.16.3", "@babel/runtime@^7.16.7", "@babel/runtime@^7.3.1", "@babel/runtime@^7.5.0", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2": version "7.17.7" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.17.7.tgz#a5f3328dc41ff39d803f311cfe17703418cf9825" integrity sha512-L6rvG9GDxaLgFjg41K+5Yv9OMrU98sWe+Ykmc6FDJW/+vYZMhdOMKkISgzptMaERHvS2Y2lw9MDRm2gHhlQQoA== @@ -10976,7 +10976,7 @@ pnp-webpack-plugin@1.6.4: dependencies: ts-pnp "^1.1.6" -polished@^4.0.5: +polished@^4.0.5, polished@^4.1.4: version "4.1.4" resolved "https://registry.yarnpkg.com/polished/-/polished-4.1.4.tgz#640293ba834109614961a700fdacbb6599fb12d0" integrity sha512-Nq5Mbza+Auo7N3sQb1QMFaQiDO+4UexWuSGR7Cjb4Sw11SZIJcrrFtiZ+L0jT9MBsUsxDboHVASbCLbE1rnECg==