diff --git a/package.json b/package.json index 1b7ea214..fc4d1ea5 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "@types/react-router-dom": "^5.1.3", "@types/react-textarea-autosize": "^4.3.5", "@types/react-toastify": "^4.1.0", + "@types/react-virtualized": "^9.21.8", "@types/sanitize-html": "^1.20.2", "@types/styled-components": "^4.4.1", "@types/throttle-debounce": "^2.1.0", @@ -120,6 +121,7 @@ "react-textarea-autosize": "^7.1.2", "react-toastify": "^5.5.0", "react-use": "^13.12.2", + "react-virtualized": "^9.21.2", "redux": "^4.0.4", "redux-devtools-extension": "^2.13.8", "remark": "^11.0.2", diff --git a/src/App.tsx b/src/App.tsx index 84d8e42d..d0346fd3 100755 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,6 +12,7 @@ import VelogPageFallback from './containers/velog/VelogPageFallback'; import ErrorBoundary from './components/error/ErrorBoundary'; import NotFoundPage from './pages/NotFoundPage'; import { Helmet } from 'react-helmet-async'; +import HomePage from './pages/home/HomePage'; const loadableConfig = { fallback: , @@ -57,12 +58,13 @@ const App: React.FC = props => { - - + + + {/* */} diff --git a/src/components/base/Header.tsx b/src/components/base/Header.tsx index 336c281d..7ee2766e 100644 --- a/src/components/base/Header.tsx +++ b/src/components/base/Header.tsx @@ -12,15 +12,16 @@ import HeaderLogo from './HeaderLogo'; import media from '../../lib/styles/media'; import { SearchIcon2 } from '../../static/svg'; import { Link } from 'react-router-dom'; +import HomeResponsive from '../home/HomeResponsive'; const HeaderBlock = styled.div<{ floating: boolean }>` width: 100%; - > .wrapper { - width: 1200px; + .wrapper { + /* width: 1200px; */ + width: 100%; height: 4rem; - margin: 0 auto; - padding-left: 1rem; - padding-right: 1rem; + /* padding-left: 1rem; + padding-right: 1rem; */ display: flex; justify-content: space-between; align-items: center; @@ -36,10 +37,10 @@ const HeaderBlock = styled.div<{ floating: boolean }>` } ${media.large} { - width: 1024px; + /* width: 1024px; */ } ${media.medium} { - width: 100%; + /* width: 100%; */ .write-button { display: none; } @@ -122,63 +123,65 @@ const Header: React.FC = ({ style={{ marginTop: floating ? floatingMargin : 0 }} data-testid="Header" > -
-
- -
-
- {/* {velogUsername ? ( + +
+
+ +
+
+ {/* {velogUsername ? ( ) : ( )} */} - {!isSearch && ( - - - - )} - {user ? ( -
+ {!isSearch && ( + + + + )} + {user ? ( +
+ + 새 글 작성 + + + +
+ ) : ( - 새 글 작성 + 로그인 - - -
- ) : ( - - 로그인 - - )} + )} +
-
+ {floating && } diff --git a/src/components/common/FlatPostCard.tsx b/src/components/common/FlatPostCard.tsx new file mode 100644 index 00000000..4a0a1e5b --- /dev/null +++ b/src/components/common/FlatPostCard.tsx @@ -0,0 +1,299 @@ +import React, { useRef } from 'react'; +import { Link } from 'react-router-dom'; +import styled from 'styled-components'; +import palette from '../../lib/styles/palette'; +import { userThumbnail } from '../../static/images'; +import Tag from './TagItem'; +import { PartialPost } from '../../lib/graphql/post'; +import { formatDate } from '../../lib/utils'; +import usePrefetchPost from '../../lib/hooks/usePrefetchPost'; +import Skeleton from './Skeleton'; +import SkeletonTexts from './SkeletonTexts'; +import RatioImage from './RatioImage'; +import media from '../../lib/styles/media'; +import PrivatePostLabel from './PrivatePostLabel'; +import optimizeImage from '../../lib/optimizeImage'; + +const PostCardBlock = styled.div` + padding-top: 4rem; + padding-bottom: 4rem; + ${media.small} { + padding-top: 2rem; + padding-bottom: 2rem; + } + + & > a { + color: inherit; + text-decoration: none; + } + &:first-child { + padding-top: 0; + } + .user-info { + display: flex; + align-items: center; + img { + width: 3rem; + height: 3rem; + display: block; + margin-right: 1rem; + background: ${palette.gray0}; + object-fit: cover; + border-radius: 1.5rem; + box-shadow: 0px 0 8px rgba(0, 0, 0, 0.1); + ${media.small} { + width: 2rem; + height: 2rem; + border-radius: 1rem; + } + } + .username { + font-size: 0.875rem; + color: ${palette.gray9}; + font-weight: bold; + a { + color: inherit; + text-decoration: none; + &:hover { + color: ${palette.gray8}; + } + } + } + margin-bottom: 1.5rem; + ${media.small} { + margin-bottom: 0.75rem; + } + } + .post-thumbnail { + margin-bottom: 1rem; + ${media.small} { + } + } + line-height: 1.5; + h2 { + font-size: 1.5rem; + margin: 0; + color: ${palette.gray9}; + word-break: keep-all; + ${media.small} { + font-size: 1rem; + } + } + p { + margin-bottom: 2rem; + margin-top: 0.5rem; + font-size: 1rem; + color: ${palette.gray7}; + word-break: keep-all; + overflow-wrap: break-word; + ${media.small} { + font-size: 0.875rem; + margin-bottom: 1.5rem; + } + } + .subinfo { + display: flex; + align-items: center; + margin-top: 1rem; + color: ${palette.gray6}; + font-size: 0.875rem; + ${media.small} { + font-size: 0.75rem; + } + span { + } + .separator { + margin-left: 0.5rem; + margin-right: 0.5rem; + } + } + .tags-wrapper { + margin-bottom: -0.875rem; + ${media.small} { + margin-bottom: -0.5rem; + } + } + + & + & { + border-top: 1px solid ${palette.gray2}; + } +`; + +interface PostCardProps { + post: PartialPost; + hideUser?: boolean; +} + +const FlatPostCard = ({ post, hideUser }: PostCardProps) => { + const prefetch = usePrefetchPost(post.user.username, post.url_slug); + const prefetchTimeoutId = useRef(null); + + const onMouseEnter = () => { + prefetchTimeoutId.current = setTimeout(prefetch, 2000); + }; + + const onMouseLeave = () => { + if (prefetchTimeoutId.current) { + clearTimeout(prefetchTimeoutId.current); + } + }; + + const url = `/@${post.user.username}/${post.url_slug}`; + const velogUrl = `/@${post.user.username}`; + + if (!post.user.profile) { + console.log(post); + } + return ( + + {!hideUser && ( +
+ + thumbnail + +
+ {post.user.username} +
+
+ )} + {post.thumbnail && ( + + + + )} + +

{post.title}

+ +

{post.short_description}

+
+ {post.tags.map(tag => ( + + ))} +
+
+ {formatDate(post.released_at)} +
·
+ {post.comments_count}개의 댓글 + {post.is_private && ( + <> +
·
+ + + + + )} +
+
+ ); +}; + +export type PostCardSkeletonProps = { + hideUser?: boolean; +}; + +export function PostCardSkeleton({ hideUser }: PostCardSkeletonProps) { + return ( + + {!hideUser && ( +
+ +
+ +
+
+ )} +
+
+ +
+
+

+ +

+
+
+ +
+
+ +
+
+ +
+
+
+ + + +
+
+ + +
+
+ ); +} + +const SkeletonBlock = styled(PostCardBlock)` + h2 { + display: flex; + margin-top: 1.375rem; + margin-bottom: 0.375rem; + } + .user-thumbnail-skeleton { + width: 3rem; + height: 3rem; + ${media.small} { + width: 2rem; + height: 2rem; + } + } + .thumbnail-skeleton-wrapper { + width: 100%; + padding-top: 52.35%; + position: relative; + .skeleton { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + } + } + .short-description { + margin-bottom: 2rem; + margin-top: 1rem; + font-size: 1rem; + .line { + display: flex; + } + .line + .line { + margin-top: 0.5rem; + } + } + .tags-skeleton { + line-height: 1; + font-size: 2rem; + ${media.small} { + font-size: 1.25rem; + } + } +`; + +export default React.memo(FlatPostCard); diff --git a/src/components/common/PostCardList.tsx b/src/components/common/FlatPostCardList.tsx similarity index 94% rename from src/components/common/PostCardList.tsx rename to src/components/common/FlatPostCardList.tsx index 6c3e047b..4d6cc919 100644 --- a/src/components/common/PostCardList.tsx +++ b/src/components/common/FlatPostCardList.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import styled from 'styled-components'; -import PostCard, { PostCardSkeleton } from './PostCard'; +import PostCard, { PostCardSkeleton } from './FlatPostCard'; import { PartialPost } from '../../lib/graphql/post'; import palette from '../../lib/styles/palette'; diff --git a/src/components/common/PostCard.tsx b/src/components/common/PostCard.tsx index d75b0ebb..03ab089a 100644 --- a/src/components/common/PostCard.tsx +++ b/src/components/common/PostCard.tsx @@ -1,130 +1,26 @@ import React, { useRef } from 'react'; -import { Link } from 'react-router-dom'; -import styled from 'styled-components'; +import styled, { css } from 'styled-components'; +import RatioImage from './RatioImage'; +import { ellipsis } from '../../lib/styles/utils'; import palette from '../../lib/styles/palette'; -import { userThumbnail } from '../../static/images'; -import Tag from './TagItem'; +import { LikeIcon } from '../../static/svg'; import { PartialPost } from '../../lib/graphql/post'; import { formatDate } from '../../lib/utils'; -import usePrefetchPost from '../../lib/hooks/usePrefetchPost'; -import Skeleton from './Skeleton'; -import SkeletonTexts from './SkeletonTexts'; -import RatioImage from './RatioImage'; -import media from '../../lib/styles/media'; -import PrivatePostLabel from './PrivatePostLabel'; +import { userThumbnail } from '../../static/images'; import optimizeImage from '../../lib/optimizeImage'; +import SkeletonTexts from './SkeletonTexts'; +import Skeleton from './Skeleton'; +import { mediaQuery } from '../../lib/styles/media'; +import { Link } from 'react-router-dom'; +import usePrefetchPost from '../../lib/hooks/usePrefetchPost'; -const PostCardBlock = styled.div` - padding-top: 4rem; - padding-bottom: 4rem; - ${media.small} { - padding-top: 2rem; - padding-bottom: 2rem; - } - - & > a { - color: inherit; - text-decoration: none; - } - &:first-child { - padding-top: 0; - } - .user-info { - display: flex; - align-items: center; - img { - width: 3rem; - height: 3rem; - display: block; - margin-right: 1rem; - background: ${palette.gray0}; - object-fit: cover; - border-radius: 1.5rem; - box-shadow: 0px 0 8px rgba(0, 0, 0, 0.1); - ${media.small} { - width: 2rem; - height: 2rem; - border-radius: 1rem; - } - } - .username { - font-size: 0.875rem; - color: ${palette.gray9}; - font-weight: bold; - a { - color: inherit; - text-decoration: none; - &:hover { - color: ${palette.gray8}; - } - } - } - margin-bottom: 1.5rem; - ${media.small} { - margin-bottom: 0.75rem; - } - } - .post-thumbnail { - margin-bottom: 1rem; - ${media.small} { - } - } - line-height: 1.5; - h2 { - font-size: 1.5rem; - margin: 0; - color: ${palette.gray9}; - word-break: keep-all; - ${media.small} { - font-size: 1rem; - } - } - p { - margin-bottom: 2rem; - margin-top: 0.5rem; - font-size: 1rem; - color: ${palette.gray7}; - word-break: keep-all; - overflow-wrap: break-word; - ${media.small} { - font-size: 0.875rem; - margin-bottom: 1.5rem; - } - } - .subinfo { - display: flex; - align-items: center; - margin-top: 1rem; - color: ${palette.gray6}; - font-size: 0.875rem; - ${media.small} { - font-size: 0.75rem; - } - span { - } - .separator { - margin-left: 0.5rem; - margin-right: 0.5rem; - } - } - .tags-wrapper { - margin-bottom: -0.875rem; - ${media.small} { - margin-bottom: -0.5rem; - } - } - - & + & { - border-top: 1px solid ${palette.gray2}; - } -`; - -interface PostCardProps { +export type PostCardProps = { post: PartialPost; - hideUser?: boolean; -} +}; + +function PostCard({ post }: PostCardProps) { + const url = `/@${post.user.username}/${post.url_slug}`; -const PostCard = ({ post, hideUser }: PostCardProps) => { const prefetch = usePrefetchPost(post.user.username, post.url_slug); const prefetchTimeoutId = useRef(null); @@ -138,137 +34,236 @@ const PostCard = ({ post, hideUser }: PostCardProps) => { } }; - const url = `/@${post.user.username}/${post.url_slug}`; - const velogUrl = `/@${post.user.username}`; - - if (!post.user.profile) { - console.log(post); - } return ( - - {!hideUser && ( -
- - thumbnail - -
- {post.user.username} -
-
- )} + {post.thumbnail && ( - + - + )} - -

{post.title}

- -

{post.short_description}

-
- {post.tags.map(tag => ( - - ))} -
-
- {formatDate(post.released_at)} -
·
- {post.comments_count}개의 댓글 - {post.is_private && ( - <> -
·
- - - - - )} -
-
+ + +

{post.title}

+
+

+ {post.short_description.replace(/:/g, ':')} + {post.short_description.length === 150 && '...'} +

+
+
+
+ {formatDate(post.released_at)} + · + {post.comments_count}개의 댓글 +
+
+
+ + {`user + + by {post.user.username} + + +
+ + {post.likes} +
+
+ ); -}; - -export type PostCardSkeletonProps = { - hideUser?: boolean; -}; +} -export function PostCardSkeleton({ hideUser }: PostCardSkeletonProps) { +export function PostCardSkeleton() { return ( - {!hideUser && ( -
- -
- -
-
- )} -
-
- -
+
+
-

- -

-
-
- + +

+ +

+
+
+
+ +
+
+ +
+
-
- +
+ + + + + + +
-
- + +
+
+ + + +
-
-
- - - -
-
- - -
+ ); } -const SkeletonBlock = styled(PostCardBlock)` - h2 { +const StyledLink = styled(Link)` + display: block; + color: inherit; + text-decoration: none; +`; + +const Block = styled.div` + width: 20rem; + background: white; + border-radius: 4px; + box-shadow: 0 4px 16px 0 rgba(0, 0, 0, 0.04); + transition: 0.25s box-shadow ease-in, 0.25s transform ease-in; + &:hover { + transform: translateY(-8px); + box-shadow: 0 12px 20px 0 rgba(0, 0, 0, 0.08); + ${mediaQuery(1024)} { + transform: none; + } + } + margin: 1rem; + overflow: hidden; + display: flex; + flex-direction: column; + ${mediaQuery(944)} { + width: calc(50% - 2rem); + } + ${mediaQuery(767)} { + margin: 0; + width: 100%; + & + & { + margin-top: 1rem; + } + } +`; + +const Content = styled.div<{ clamp: boolean }>` + padding: 1rem; + display: flex; + flex: 1; + flex-direction: column; + h4 { + font-size: 1rem; + margin: 0; + margin-bottom: 0.25rem; + line-height: 1.5; + ${ellipsis} + color: ${palette.gray9}; + ${mediaQuery(767)} { + white-space: initial; + } + } + .description-wrapper { + flex: 1; + } + p { + margin: 0; + word-break: break-word; + overflow-wrap: break-word; + font-size: 0.875rem; + line-height: 1.5; + ${props => + props.clamp && + css` + height: 3.9375rem; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + `} + /* ${props => + !props.clamp && + css` + height: 15.875rem; + `} */ + + color: ${palette.gray7}; + margin-bottom: 1.5rem; + } + .sub-info { + font-size: 0.75rem; + line-height: 1.5; + color: ${palette.gray6}; + .separator { + margin-left: 0.25rem; + margin-right: 0.25rem; + } + } +`; + +const Footer = styled.div` + padding: 0.625rem 1rem; + border-top: 1px solid ${palette.gray0}; + display: flex; + font-size: 0.75rem; + line-height: 1.5; + justify-content: space-between; + .userinfo { + text-decoration: none; + color: inherit; display: flex; - margin-top: 1.375rem; - margin-bottom: 0.375rem; + align-items: center; + img { + object-fit: cover; + border-radius: 50%; + width: 1.5rem; + height: 1.5rem; + display: block; + margin-right: 0.5rem; + } + span { + color: ${palette.gray6}; + b { + color: ${palette.gray8}; + } + } } - .user-thumbnail-skeleton { - width: 3rem; - height: 3rem; - ${media.small} { - width: 2rem; - height: 2rem; + .likes { + display: flex; + align-items: center; + svg { + width: 0.75rem; + height: 0.75rem; + margin-right: 0.5rem; } } - .thumbnail-skeleton-wrapper { +`; + +const SkeletonBlock = styled(Block)` + .skeleton-thumbnail-wrapper { width: 100%; - padding-top: 52.35%; + padding-top: 52.19%; position: relative; - .skeleton { + .skeleton-thumbnail { position: absolute; top: 0; left: 0; @@ -276,22 +271,15 @@ const SkeletonBlock = styled(PostCardBlock)` height: 100%; } } - .short-description { - margin-bottom: 2rem; - margin-top: 1rem; - font-size: 1rem; + .lines { + height: 3.9375rem; + font-size: 0.875rem; + margin-bottom: 1.5rem; .line { display: flex; } .line + .line { - margin-top: 0.5rem; - } - } - .tags-skeleton { - line-height: 1; - font-size: 2rem; - ${media.small} { - font-size: 1.25rem; + margin-top: 0.3rem; } } `; diff --git a/src/components/common/PostCardGrid.tsx b/src/components/common/PostCardGrid.tsx new file mode 100644 index 00000000..84416512 --- /dev/null +++ b/src/components/common/PostCardGrid.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import styled from 'styled-components'; +import PostCard, { PostCardSkeleton } from './PostCard'; +import { PartialPost } from '../../lib/graphql/post'; +import { mediaQuery } from '../../lib/styles/media'; + +export type PostCardGridProps = { + posts: PartialPost[]; +}; + +function PostCardGrid({ posts }: PostCardGridProps) { + return ( + + {posts.map(post => ( + + ))} + + ); +} + +export function PostCardGridSkeleton() { + return ( + + {Array.from({ length: 8 }).map((_, i) => ( + + ))} + + ); +} + +const Block = styled.div` + display: flex; + margin: -1rem; + flex-wrap: wrap; + ${mediaQuery(767)} { + margin: 0; + } +`; + +export default PostCardGrid; diff --git a/src/components/common/RoundButton.tsx b/src/components/common/RoundButton.tsx index 4b694b61..eb17f6be 100644 --- a/src/components/common/RoundButton.tsx +++ b/src/components/common/RoundButton.tsx @@ -63,7 +63,7 @@ const RoundButtonBlock = styled.button` ${props => props.border && css` - background: transparent; + background: white; border: 1px solid ${props => buttonColorMap[props.color].background}; color: ${props => buttonColorMap[props.color].background}; &:hover { diff --git a/src/components/home/FloatingHomeHeader.tsx b/src/components/home/FloatingHomeHeader.tsx new file mode 100644 index 00000000..cd8741a0 --- /dev/null +++ b/src/components/home/FloatingHomeHeader.tsx @@ -0,0 +1,93 @@ +import React, { useEffect, useCallback, useRef, useState } from 'react'; +import styled from 'styled-components'; +import HomeHeader from './HomeHeader'; +import HomeTab from './HomeTab'; +import HomeResponsive from './HomeResponsive'; +import { getScrollTop } from '../../lib/utils'; + +export type FloatingHomeHeaderProps = {}; + +function FloatingHomeHeader(props: FloatingHomeHeaderProps) { + const [visible, setVisible] = useState(false); + const [marginTop, setMarginTop] = useState(-102); + + const prevScrollTop = useRef(0); + const direction = useRef<'UP' | 'DOWN'>('DOWN'); + const transitionPoint = useRef(0); + + const onScroll = useCallback(() => { + const scrollTop = getScrollTop(); + const nextDirection = prevScrollTop.current > scrollTop ? 'UP' : 'DOWN'; + + if ( + direction.current === 'DOWN' && + nextDirection === 'UP' && + transitionPoint.current - scrollTop < 0 + ) { + setVisible(true); + transitionPoint.current = scrollTop; + } + + if ( + direction.current === 'UP' && + nextDirection === 'DOWN' && + scrollTop - transitionPoint.current < -102 + ) { + transitionPoint.current = scrollTop + 102; + } + + if (scrollTop < 64) { + setVisible(false); + } + + setMarginTop(Math.min(0, -102 + transitionPoint.current - scrollTop)); + + direction.current = nextDirection; + prevScrollTop.current = scrollTop; + }, []); + + useEffect(() => { + document.addEventListener('scroll', onScroll); + return () => { + document.removeEventListener('scroll', onScroll); + }; + }, [onScroll]); + + return ( + + +
+ + + +
+
+
+ ); +} + +const Block = styled.div` + position: fixed; + top: 0; + background: white; + width: 100%; + z-index: 10; + + box-shadow: 0px 0 8px rgba(0, 0, 0, 0.08); + .tab-wrapper { + margin-top: -2rem; + } +`; + +export default FloatingHomeHeader; diff --git a/src/components/home/HomeHeader.tsx b/src/components/home/HomeHeader.tsx new file mode 100644 index 00000000..1a10527d --- /dev/null +++ b/src/components/home/HomeHeader.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import styled from 'styled-components'; +import { Logo, SearchIcon2 } from '../../static/svg'; +import RoundButton from '../common/RoundButton'; +import HomeResponsive from './HomeResponsive'; +import useHeader from './hooks/useHeader'; +import HeaderUserIcon from '../base/HeaderUserIcon'; +import useToggle from '../../lib/hooks/useToggle'; +import HeaderUserMenu from '../base/HeaderUserMenu'; +import { Link } from 'react-router-dom'; +import { mediaQuery } from '../../lib/styles/media'; + +export type HomeHeaderProps = {}; + +function HomeHeader(props: HomeHeaderProps) { + const { user, onLoginClick, onLogout } = useHeader(); + const [userMenu, toggleUserMenu] = useToggle(false); + + return ( + + + + {user ? ( + + + + + + 새 글 작성 + + + + + + ) : ( + + + + + + 로그인 + + + )} + + + ); +} + +const Block = styled.div` + height: 4rem; +`; + +const SearchButton = styled(Link)` + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + width: 2.5rem; + height: 2.5rem; + outline: none; + border-radius: 50%; + cursor: pointer; + &:hover { + background: rgba(0, 0, 0, 0.045); + } + svg { + width: 1.125rem; + height: 1.125rem; + } + margin-right: 0.75rem; +`; + +const Inner = styled(HomeResponsive)` + height: 100%; + display: flex; + align-items: center; + justify-content: space-between; +`; + +const Right = styled.div` + display: flex; + align-items: center; + position: relative; + .write-button { + ${mediaQuery(376)} { + display: none; + } + } +`; + +export default HomeHeader; diff --git a/src/components/home/HomeLayout.tsx b/src/components/home/HomeLayout.tsx new file mode 100644 index 00000000..8bc03961 --- /dev/null +++ b/src/components/home/HomeLayout.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import styled from 'styled-components'; +import { mediaQuery } from '../../lib/styles/media'; + +export type HomeLayoutProps = { + main: React.ReactNode; + side: React.ReactNode; +}; + +function HomeLayout({ main, side }: HomeLayoutProps) { + return ( + +
{main}
+ {side} +
+ ); +} + +const Block = styled.div` + display: flex; + margin-top: 2rem; +`; +const Main = styled.main` + flex: 1; +`; +const Side = styled.aside` + margin-left: 6rem; + width: 16rem; + ${mediaQuery(1440)} { + margin-left: 3rem; + width: 12rem; + } + ${mediaQuery(944)} { + display: none; + } +`; + +export default HomeLayout; diff --git a/src/components/home/HomeResponsive.tsx b/src/components/home/HomeResponsive.tsx new file mode 100644 index 00000000..fff46d05 --- /dev/null +++ b/src/components/home/HomeResponsive.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import styled from 'styled-components'; +import { mediaQuery } from '../../lib/styles/media'; + +export type HomeResponsiveProps = { + className?: string; + children: React.ReactNode; +}; + +function HomeResponsive({ className, children }: HomeResponsiveProps) { + return {children}; +} + +const Block = styled.div` + width: 1728px; + margin-left: auto; + margin-right: auto; + ${mediaQuery(1919)} { + width: 1376px; + } + ${mediaQuery(1440)} { + width: 1280px; + } + ${mediaQuery(1312)} { + width: 912px; + } + ${mediaQuery(944)} { + width: calc(100% - 2rem); + } + ${mediaQuery(767)} { + width: calc(100% - 2rem); + } +`; + +export default HomeResponsive; diff --git a/src/components/home/HomeSidebar.tsx b/src/components/home/HomeSidebar.tsx new file mode 100644 index 00000000..9495daac --- /dev/null +++ b/src/components/home/HomeSidebar.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import styled from 'styled-components'; +import MainNoticeWidgetContainer from '../../containers/main/MainNoticeWidgetContainer'; +import MainTagWidgetContainer from '../../containers/main/MainTagWidgetContainer'; +import MainRightFooter from '../main/MainRightFooter'; +import Sticky from '../common/Sticky'; + +export type HomeSidebarProps = {}; + +function HomeSidebar(props: HomeSidebarProps) { + return ( + + + + + + + + ); +} + +const Block = styled.div``; + +export default HomeSidebar; diff --git a/src/components/home/HomeTab.tsx b/src/components/home/HomeTab.tsx new file mode 100644 index 00000000..86498a62 --- /dev/null +++ b/src/components/home/HomeTab.tsx @@ -0,0 +1,132 @@ +import React, { useRef } from 'react'; +import styled from 'styled-components'; +import { NavLink, useLocation } from 'react-router-dom'; +import palette from '../../lib/styles/palette'; +import { MdTrendingUp, MdAccessTime, MdMoreVert } from 'react-icons/md'; +import { useSpring, animated } from 'react-spring'; +import { mediaQuery } from '../../lib/styles/media'; +import useToggle from '../../lib/hooks/useToggle'; +import MainMobileHeadExtra from '../../components/main/MainMobileHeadExtra'; + +export type HomeTabProps = {}; + +function HomeTab(props: HomeTabProps) { + const location = useLocation(); + + const isRecent = location.pathname === '/recent'; + const [extra, toggle] = useToggle(false); + const moreButtonRef = useRef(null); + + const onClose = (e: React.MouseEvent) => { + if (!moreButtonRef.current) return; + if ( + e.target === moreButtonRef.current || + moreButtonRef.current.contains(e.target as Node) + ) { + return; + } + toggle(); + }; + + const springStyle = useSpring({ + left: isRecent ? '50%' : '0%', + config: { + friction: 16, + tensiton: 160, + }, + }); + + return ( + + + { + return ['/', '/trending'].indexOf(location.pathname) !== -1; + }} + > + + 트렌딩 + + + + 최신 + + + + + + + + + ); +} + +const Wrapper = styled.div` + margin-top: 1.5rem; + display: flex; + justify-content: space-between; + align-items: center; + position: relative; + .more { + cursor: pointer; + font-size: 1.5rem; + color: ${palette.gray6}; + } +`; + +const MobileMore = styled.div` + display: none; + ${mediaQuery(944)} { + display: flex; + align-items: center; + justify-content: center; + } +`; + +const Block = styled.div` + display: flex; + position: relative; + width: 14rem; + ${mediaQuery(944)} { + width: 10rem; + } + a { + width: 7rem; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.125rem; + text-decoration: none; + color: ${palette.gray6}; + height: 2.875rem; + + svg { + font-size: 1.5rem; + margin-right: 0.5rem; + } + &.active { + color: ${palette.gray8}; + font-weight: bold; + } + + ${mediaQuery(944)} { + font-size: 1rem; + width: 5rem; + svg { + font-size: 1.25rem; + } + } + } +`; + +const Indicator = styled(animated.div)` + width: 50%; + height: 2px; + position: absolute; + bottom: 0px; + background: ${palette.gray8}; +`; + +export default HomeTab; diff --git a/src/components/home/HomeTemplate.tsx b/src/components/home/HomeTemplate.tsx new file mode 100644 index 00000000..5ace3bdb --- /dev/null +++ b/src/components/home/HomeTemplate.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import styled, { createGlobalStyle } from 'styled-components'; +import palette from '../../lib/styles/palette'; +import { Link } from 'react-router-dom'; + +const BackgroundStyle = createGlobalStyle` + body { + background: ${palette.gray0}; + } +`; + +export type HomeTemplateProps = { + children: React.ReactNode; +}; + +function HomeTemplate({ children }: HomeTemplateProps) { + return ( + <> + + {children} + + ); +} + +const Block = styled.div``; + +export default HomeTemplate; diff --git a/src/components/home/hooks/useHeader.ts b/src/components/home/hooks/useHeader.ts new file mode 100644 index 00000000..27cce473 --- /dev/null +++ b/src/components/home/hooks/useHeader.ts @@ -0,0 +1,25 @@ +import { useDispatch, useSelector } from 'react-redux'; +import { useCallback } from 'react'; +import { showAuthModal } from '../../../modules/core'; +import { RootState } from '../../../modules'; +import { logout } from '../../../lib/api/auth'; +import storage from '../../../lib/storage'; + +export default function useHeader() { + const dispatch = useDispatch(); + const user = useSelector((state: RootState) => state.core.user); + + const onLoginClick = useCallback(() => { + dispatch(showAuthModal('LOGIN')); + }, [dispatch]); + + const onLogout = useCallback(async () => { + try { + await logout(); + } catch {} + storage.removeItem('CURRENT_USER'); + window.location.href = '/'; + }, []); + + return { user, onLoginClick, onLogout }; +} diff --git a/src/components/main/MainRightFooter.tsx b/src/components/main/MainRightFooter.tsx index d0ccc00f..2652f138 100644 --- a/src/components/main/MainRightFooter.tsx +++ b/src/components/main/MainRightFooter.tsx @@ -2,9 +2,13 @@ import * as React from 'react'; import styled from 'styled-components'; import palette from '../../lib/styles/palette'; import { Link } from 'react-router-dom'; +import media from '../../lib/styles/media'; const MainRightFooterBlock = styled.div` margin-top: 6.25rem; + ${media.medium} { + margin-top: 3rem; + } line-height: 1.5; font-size: 0.875rem; .links { diff --git a/src/containers/main/RecentPosts.tsx b/src/containers/main/RecentPosts.tsx index 57008c1e..26147bd4 100644 --- a/src/containers/main/RecentPosts.tsx +++ b/src/containers/main/RecentPosts.tsx @@ -1,7 +1,7 @@ import React, { useCallback } from 'react'; import PostCardList, { PostCardListSkeleton, -} from '../../components/common/PostCardList'; +} from '../../components/common/FlatPostCardList'; import { GET_POST_LIST, PartialPost } from '../../lib/graphql/post'; import { useQuery } from '@apollo/react-hooks'; import useScrollPagination from '../../lib/hooks/useScrollPagination'; diff --git a/src/containers/main/TrendingPosts.tsx b/src/containers/main/TrendingPosts.tsx index 5011d646..a2220d69 100644 --- a/src/containers/main/TrendingPosts.tsx +++ b/src/containers/main/TrendingPosts.tsx @@ -1,7 +1,7 @@ import React, { useCallback } from 'react'; import PostCardList, { PostCardListSkeleton, -} from '../../components/common/PostCardList'; +} from '../../components/common/FlatPostCardList'; import { GET_TRENDING_POSTS, GetTrendingPostsResponse, @@ -66,7 +66,7 @@ const TrendingPosts: React.FC = props => { - {loading && } + ); }; diff --git a/src/containers/search/SearchResult.tsx b/src/containers/search/SearchResult.tsx index 2386ffdb..eadc000b 100644 --- a/src/containers/search/SearchResult.tsx +++ b/src/containers/search/SearchResult.tsx @@ -1,6 +1,6 @@ import React, { useCallback } from 'react'; import SearchResultInfo from '../../components/search/SearchResultInfo'; -import PostCardList from '../../components/common/PostCardList'; +import PostCardList from '../../components/common/FlatPostCardList'; import { useQuery } from '@apollo/react-hooks'; import { SearchPostsResponse, SEARCH_POSTS } from '../../lib/graphql/post'; import useScrollPagination from '../../lib/hooks/useScrollPagination'; diff --git a/src/containers/tags/TagDetailContainer.tsx b/src/containers/tags/TagDetailContainer.tsx index 107dcdaf..4e7f2545 100644 --- a/src/containers/tags/TagDetailContainer.tsx +++ b/src/containers/tags/TagDetailContainer.tsx @@ -7,7 +7,7 @@ import { safe, ssrEnabled } from '../../lib/utils'; import useScrollPagination from '../../lib/hooks/useScrollPagination'; import PostCardList, { PostCardListSkeleton, -} from '../../components/common/PostCardList'; +} from '../../components/common/FlatPostCardList'; import useNotFound from '../../lib/hooks/useNotFound'; import { Helmet } from 'react-helmet-async'; diff --git a/src/containers/velog/UserPosts.tsx b/src/containers/velog/UserPosts.tsx index 74e9ad47..cc18a4e3 100644 --- a/src/containers/velog/UserPosts.tsx +++ b/src/containers/velog/UserPosts.tsx @@ -1,7 +1,7 @@ import React, { useCallback } from 'react'; import PostCardList, { PostCardListSkeleton, -} from '../../components/common/PostCardList'; +} from '../../components/common/FlatPostCardList'; import { GET_POST_LIST, PartialPost } from '../../lib/graphql/post'; import { useQuery } from '@apollo/react-hooks'; import PaginateWithScroll from '../../components/common/PaginateWithScroll'; diff --git a/src/lib/graphql/post.ts b/src/lib/graphql/post.ts index 9a9b8bd3..03425d77 100644 --- a/src/lib/graphql/post.ts +++ b/src/lib/graphql/post.ts @@ -54,6 +54,7 @@ export type PartialPost = { updated_at: string; tags: string[]; comments_count: number; + likes: number; }; // Generated by https://quicktype.io @@ -136,12 +137,14 @@ export const GET_POST_LIST = gql` $username: String $temp_only: Boolean $tag: String + $limit: Int ) { posts( cursor: $cursor username: $username temp_only: $temp_only tag: $tag + limit: $limit ) { id title @@ -161,6 +164,7 @@ export const GET_POST_LIST = gql` comments_count tags is_private + likes } } `; @@ -172,6 +176,7 @@ export const GET_TRENDING_POSTS = gql` title short_description thumbnail + likes user { id username diff --git a/src/lib/hooks/useScrollPagination.ts b/src/lib/hooks/useScrollPagination.ts index e353a9c0..bb7d12d8 100644 --- a/src/lib/hooks/useScrollPagination.ts +++ b/src/lib/hooks/useScrollPagination.ts @@ -1,5 +1,5 @@ import { useRef, useCallback, useEffect } from 'react'; -import { getScrollBottom } from '../utils'; +import { getScrollBottom, getScrollTop } from '../utils'; type Params = { offset?: number | null; @@ -18,19 +18,28 @@ export default function useScrollPagination({ }: Params) { const last = useRef(null); - const loadMore = useCallback(() => { + const preventBottomStick = useCallback(() => { + console.log(getScrollBottom()); + if (getScrollBottom() === 0) { + window.scrollTo(0, getScrollTop() - 1); + } + }, []); + + const loadMore = useCallback(async () => { if (!cursor || !onLoadMore) return; if (cursor === last.current) return; - onLoadMore(cursor); last.current = cursor; - }, [cursor, onLoadMore]); + await onLoadMore(cursor); + preventBottomStick(); + }, [cursor, onLoadMore, preventBottomStick]); - const loadMoreUsingOffset = useCallback(() => { + const loadMoreUsingOffset = useCallback(async () => { if (stop || !offset || !onLoadMoreByOffset) return; if (offset === last.current) return; - onLoadMoreByOffset(offset); last.current = offset; - }, [offset, onLoadMoreByOffset, stop]); + await onLoadMoreByOffset(offset); + preventBottomStick(); + }, [offset, onLoadMoreByOffset, preventBottomStick, stop]); const onScroll = useCallback(() => { const scrollBottom = getScrollBottom(); diff --git a/src/pages/home/HomePage.tsx b/src/pages/home/HomePage.tsx new file mode 100644 index 00000000..cd0cbdd4 --- /dev/null +++ b/src/pages/home/HomePage.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import HomeTemplate from '../../components/home/HomeTemplate'; +import HomeHeader from '../../components/home/HomeHeader'; +import HomeTab from '../../components/home/HomeTab'; +import HomeResponsive from '../../components/home/HomeResponsive'; +import HomeLayout from '../../components/home/HomeLayout'; +import { Route } from 'react-router-dom'; +import TrendingPostsPage from './TrendingPostsPage'; +import RecentPostsPage from './RecentPostsPage'; +import HomeSidebar from '../../components/home/HomeSidebar'; +import FloatingHomeHeader from '../../components/home/FloatingHomeHeader'; + +export type HomePageProps = {}; + +function HomePage(props: HomePageProps) { + return ( + + + + + + + + + + } + side={} + /> + + + ); +} + +export default HomePage; diff --git a/src/pages/home/RecentPostsPage.tsx b/src/pages/home/RecentPostsPage.tsx new file mode 100644 index 00000000..a846077d --- /dev/null +++ b/src/pages/home/RecentPostsPage.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import useRecentPosts from './hooks/useRecentPosts'; +import PostCardGrid, { + PostCardGridSkeleton, +} from '../../components/common/PostCardGrid'; + +export type RecentPostsPageProps = {}; + +function RecentPostsPage(props: RecentPostsPageProps) { + const { data, loading } = useRecentPosts(); + + if (!data) return ; + return ( + <> + + {loading && } + + ); +} + +export default RecentPostsPage; diff --git a/src/pages/home/TrendingPostsPage.tsx b/src/pages/home/TrendingPostsPage.tsx new file mode 100644 index 00000000..a967dbd2 --- /dev/null +++ b/src/pages/home/TrendingPostsPage.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import PostCardGrid, { + PostCardGridSkeleton, +} from '../../components/common/PostCardGrid'; +import useTrendingPosts from './hooks/useTrendingPosts'; + +export type TrendingPageProps = {}; + +function TrendingPage(props: TrendingPageProps) { + const { data, loading, isFinished } = useTrendingPosts(); + + console.log(loading); + if (!data) return ; + return ( + <> + + {data && loading && } + + ); +} + +export default TrendingPage; diff --git a/src/pages/home/hooks/useRecentPosts.ts b/src/pages/home/hooks/useRecentPosts.ts new file mode 100644 index 00000000..de990a0d --- /dev/null +++ b/src/pages/home/hooks/useRecentPosts.ts @@ -0,0 +1,48 @@ +import { useQuery } from '@apollo/react-hooks'; +import { GET_POST_LIST, PartialPost } from '../../../lib/graphql/post'; +import { useCallback, useState } from 'react'; +import useScrollPagination from '../../../lib/hooks/useScrollPagination'; + +export default function useRecentPosts() { + const { data, loading, fetchMore } = useQuery<{ posts: PartialPost[] }>( + GET_POST_LIST, + { + variables: { + limit: 24, + }, + // https://github.com/apollographql/apollo-client/issues/1617 + notifyOnNetworkStatusChange: true, + }, + ); + const [isFinished, setIsFinished] = useState(false); + + const onLoadMore = useCallback( + (cursor: string) => { + fetchMore({ + variables: { + cursor, + limit: 24, + }, + updateQuery: (prev, { fetchMoreResult }) => { + if (!fetchMoreResult) return prev; + if (fetchMoreResult.posts.length === 0) { + setIsFinished(true); + } + return { + posts: [...prev.posts, ...fetchMoreResult.posts], + }; + }, + }); + }, + [fetchMore], + ); + + const cursor = data?.posts[data?.posts.length - 1]?.id; + + useScrollPagination({ + cursor, + onLoadMore, + }); + + return { data, loading, isFinished }; +} diff --git a/src/pages/home/hooks/useTrendingPosts.ts b/src/pages/home/hooks/useTrendingPosts.ts new file mode 100644 index 00000000..6e4cefce --- /dev/null +++ b/src/pages/home/hooks/useTrendingPosts.ts @@ -0,0 +1,63 @@ +import { + GET_TRENDING_POSTS, + GetTrendingPostsResponse, +} from '../../../lib/graphql/post'; +import { useQuery } from '@apollo/react-hooks'; +import { useCallback, useState } from 'react'; +import useScrollPagination from '../../../lib/hooks/useScrollPagination'; + +export default function useTrendingPosts() { + const { data, loading, fetchMore } = useQuery( + GET_TRENDING_POSTS, + { + variables: { + limit: 24, + }, + // https://github.com/apollographql/apollo-client/issues/1617 + notifyOnNetworkStatusChange: true, + }, + ); + const [isFinished, setIsFinished] = useState(false); + + const onLoadMoreByOffset = useCallback( + (offset: number) => { + fetchMore({ + variables: { + offset, + limit: 24, + }, + updateQuery: (prev, { fetchMoreResult }) => { + if (!fetchMoreResult) return prev; + if (fetchMoreResult.trendingPosts.length === 0) { + setIsFinished(true); + } + + // filter unique posts + const idMap: Record = prev.trendingPosts.reduce( + (acc, current) => { + Object.assign(acc, { [current.id]: true }); + return acc; + }, + {}, + ); + + const uniquePosts = fetchMoreResult.trendingPosts.filter( + post => !idMap[post.id], + ); + + return { + trendingPosts: [...prev.trendingPosts, ...uniquePosts], + }; + }, + }); + }, + [fetchMore], + ); + + useScrollPagination({ + offset: data?.trendingPosts.length, + onLoadMoreByOffset, + }); + + return { data, loading, isFinished }; +} diff --git a/yarn.lock b/yarn.lock index 95b3f3d7..bc1a77f1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1842,6 +1842,14 @@ dependencies: react-toastify "*" +"@types/react-virtualized@^9.21.8": + version "9.21.8" + resolved "https://registry.yarnpkg.com/@types/react-virtualized/-/react-virtualized-9.21.8.tgz#dc0150a75fd6e42f33729886463ece04d03367ea" + integrity sha512-7fZoA0Azd2jLIE9XC37fMZgMqaJe3o3pfzGjvrzphoKjBCdT4oNl6wikvo4dDMESDnpkZ8DvVTc7aSe4DW86Ew== + dependencies: + "@types/prop-types" "*" + "@types/react" "*" + "@types/react@*", "@types/react@^16.9.0": version "16.9.16" resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.16.tgz#4f12515707148b1f53a8eaa4341dae5dfefb066d" @@ -3569,6 +3577,11 @@ clone-response@1.0.2: dependencies: mimic-response "^1.0.0" +clsx@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.1.0.tgz#62937c6adfea771247c34b54d320fb99624f5702" + integrity sha512-3avwM37fSK5oP6M5rQ9CNe99lwxhXDOeSWVPAOYF6OazUTgZCMb0yWlJpmdD74REy1gkEaFiub2ULv4fq9GUhA== + cluster-key-slot@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/cluster-key-slot/-/cluster-key-slot-1.1.0.tgz#30474b2a981fb12172695833052bc0d01336d10d" @@ -4672,7 +4685,7 @@ dom-converter@^0.2: dependencies: utila "~0.4" -dom-helpers@^5.0.1: +dom-helpers@^5.0.0, dom-helpers@^5.0.1: version "5.1.3" resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.1.3.tgz#7233248eb3a2d1f74aafca31e52c5299cc8ce821" integrity sha512-nZD1OtwfWGRBWlpANxacBEZrEuLa16o1nh7YopFWeoF68Zt8GGEmzHu6Xv4F3XaFIC+YXtTLrzgqKxFgLEe4jw== @@ -8126,7 +8139,7 @@ longest-streak@^2.0.1: resolved "https://registry.yarnpkg.com/longest-streak/-/longest-streak-2.0.3.tgz#3de7a3f47ee18e9074ded8575b5c091f5d0a4105" integrity sha512-9lz5IVdpwsKLMzQi0MQ+oD9EA0mIGcWYP7jXMTZVXP8D42PwuAk+M/HBFYQoxt1G5OR8m7aSIgb1UymfWGBWEw== -loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.1, loose-envify@^1.4.0: +loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.0, loose-envify@^1.3.1, loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== @@ -10641,6 +10654,11 @@ react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.1, react-is@^16.8.4, react-is resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.12.0.tgz#2cc0fe0fba742d97fd527c42a13bec4eeb06241c" integrity sha512-rPCkf/mWBtKc97aLL9/txD8DZdemK0vkA3JMLShjlJB3Pj3s+lpf1KaBzMfQrAmhMQB0n1cU/SUGgKKBCe837Q== +react-lifecycles-compat@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" + integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA== + react-outside-click-handler@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/react-outside-click-handler/-/react-outside-click-handler-1.3.0.tgz#3831d541ac059deecd38ec5423f81e80ad60e115" @@ -10750,6 +10768,18 @@ react-use@^13.12.2: ts-easing "^0.2.0" tslib "^1.10.0" +react-virtualized@^9.21.2: + version "9.21.2" + resolved "https://registry.yarnpkg.com/react-virtualized/-/react-virtualized-9.21.2.tgz#02e6df65c1e020c8dbf574ec4ce971652afca84e" + integrity sha512-oX7I7KYiUM7lVXQzmhtF4Xg/4UA5duSA+/ZcAvdWlTLFCoFYq1SbauJT5gZK9cZS/wdYR6TPGpX/dqzvTqQeBA== + dependencies: + babel-runtime "^6.26.0" + clsx "^1.0.1" + dom-helpers "^5.0.0" + loose-envify "^1.3.0" + prop-types "^15.6.0" + react-lifecycles-compat "^3.0.4" + react@^16.12.0: version "16.12.0" resolved "https://registry.yarnpkg.com/react/-/react-16.12.0.tgz#0c0a9c6a142429e3614834d5a778e18aa78a0b83"