diff --git a/.env.development b/.env.development new file mode 100644 index 00000000..c370e8e7 --- /dev/null +++ b/.env.development @@ -0,0 +1,7 @@ +PUBLIC_URL=/ + +REACT_APP_CLIENT_V3_HOST=http://localhost:3001 +REACT_APP_API_HOST=http://localhost:5002/ + +REACT_APP_GRAPHQL_HOST=http://localhost:5002/ +REACT_APP_GRAPHQL_HOST_NOCDN=https://v2.velog.io/ \ No newline at end of file diff --git a/.env b/.env.production similarity index 54% rename from .env rename to .env.production index 7229cfaf..034cf768 100644 --- a/.env +++ b/.env.production @@ -1,4 +1,7 @@ -REACT_APP_API_HOST=http://localhost:5001/ PUBLIC_URL=/ + +REACT_APP_CLIENT_V3_HOST=http://localhost:3001 +REACT_APP_API_HOST=http://localhost:5002/ + REACT_APP_GRAPHQL_HOST=https://v2cdn.velog.io/ REACT_APP_GRAPHQL_HOST_NOCDN=https://v2.velog.io/ \ No newline at end of file diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 6ceedb72..660d8f68 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -19,24 +19,26 @@ jobs: id: yarn-cache run: echo "::set-output name=dir::$(yarn cache dir)" - - uses: actions/cache@v1 + - uses: actions/cache@v3 with: path: ${{ steps.yarn-cache.outputs.dir }} key: ${{ runner.os }}-yarn-v1-${{ hashFiles('**/yarn.lock') }} restore-keys: | ${{ runner.os }}-yarn- - name: yarn install - uses: borales/actions-yarn@v2.0.0 + uses: borales/actions-yarn@v4.0.0 with: cmd: install - name: npm run build:ci run: npm run build:ci env: REACT_APP_API_HOST: 'https://v2.velog.io/' - REACT_APP_GRAPHQL_HOST: 'https://v2cdn.velog.io/' + REACT_APP_GRAPHQL_HOST: 'https://v2.velog.io/' REACT_APP_GRAPHQL_HOST_NOCDN: 'https://v2.velog.io/' PUBLIC_URL: 'https://static.velog.io/' REACT_APP_REDIS_HOST: ${{ secrets.REDIS_HOST }} + REACT_APP_CLIENT_V3_HOST: 'https://velog.io/' + REACT_APP_WHITELIST_IPS: ${{ secrets.REACT_APP_WHITELIST_IPS }} CI: false - name: upload env: diff --git a/.gitignore b/.gitignore index c70b7cf4..0516f767 100755 --- a/.gitignore +++ b/.gitignore @@ -29,4 +29,8 @@ jspm_packages # Serverless directories .serverless -.webpack \ No newline at end of file +.webpack + +# ignore setting +.idea +.vscode diff --git a/package.json b/package.json index 6f371341..eb77c765 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@types/jest": "^24.0.0", "@types/koa": "^2.11.0", "@types/koa-bodyparser": "^4.3.0", + "@types/koa-compress": "^4.0.6", "@types/koa-static": "^4.0.1", "@types/koa__router": "^8.0.2", "@types/loadable__component": "^5.10.0", @@ -96,6 +97,7 @@ "koa": "^2.11.0", "koa-better-http-proxy": "^0.2.4", "koa-bodyparser": "^4.2.1", + "koa-compress": "^5.1.1", "koa-static": "^5.0.0", "mini-css-extract-plugin": "0.8.0", "node-fetch": "^2.6.0", @@ -161,7 +163,7 @@ "workbox-webpack-plugin": "4.3.1" }, "scripts": { - "start": "node scripts/start.js", + "start": "node scripts/start.js --max-http-header-size=1024", "build": "node scripts/build.js", "build:ci": "node scripts/build.js && node scripts/build.server.js", "build:server": "node scripts/build.server.js", @@ -248,7 +250,7 @@ "@loadable/babel-plugin" ] }, - "proxy": "https://v2cdn.velog.io/", + "proxy": "https://v2.velog.io/", "devDependencies": { "@loadable/webpack-plugin": "^5.15.1" } diff --git a/public/index.html b/public/index.html index b85e088d..d2a80421 100644 --- a/public/index.html +++ b/public/index.html @@ -29,15 +29,16 @@ work correctly both with client-side routing and a non-root public URL. Learn how to configure a non-root public URL by running `npm run build`. --> - - + + + + React App diff --git a/src/components/auth/AuthSocialButton.tsx b/src/components/auth/AuthSocialButton.tsx index bb0117a1..e5ae4510 100644 --- a/src/components/auth/AuthSocialButton.tsx +++ b/src/components/auth/AuthSocialButton.tsx @@ -62,7 +62,7 @@ const AuthSocialButton: React.FC = ({ const host = process.env.NODE_ENV === 'production' ? process.env.REACT_APP_API_HOST - : 'http://localhost:5001/'; + : 'http://localhost:5002/'; const redirectTo = `${host}api/v2/auth/social/redirect/${provider}?next=${currentPath}&isIntegrate=${ isIntegrate ? 1 : 0 diff --git a/src/components/base/Header.tsx b/src/components/base/Header.tsx index 26b370fc..a1387234 100644 --- a/src/components/base/Header.tsx +++ b/src/components/base/Header.tsx @@ -1,6 +1,6 @@ import React, { useRef, useCallback } from 'react'; -import styled from 'styled-components'; -import { SearchIcon2 } from '../../static/svg'; +import styled, { css } from 'styled-components'; +import { NotificationIcon, SearchIcon3 } from '../../static/svg'; import RoundButton from '../common/RoundButton'; import MainResponsive from '../main/MainResponsive'; import useHeader from './hooks/useHeader'; @@ -11,19 +11,28 @@ import { Link } from 'react-router-dom'; import media from '../../lib/styles/media'; import HeaderLogo from './HeaderLogo'; import { themedPalette } from '../../lib/styles/themes'; -import ThemeToggleButton from './ThemeToggleButton'; -import { useSelector } from 'react-redux'; -import { RootState } from '../../modules'; +import VLink from '../common/VLink'; +import { useDispatch } from 'react-redux'; +import { showAuthModal } from '../../modules/core'; +import { useQuery } from '@apollo/react-hooks'; +import { NOTIFICATION_COUNT } from '../../lib/graphql/notification'; export type MainHeaderProps = {}; function Header(props: MainHeaderProps) { + const dispatch = useDispatch(); + const { user, onLoginClick, onLogout, customHeader } = useHeader(); + const { data: notificationCountData, refetch } = useQuery( + NOTIFICATION_COUNT, + { + fetchPolicy: 'network-only', + skip: !user, + }, + ); + const [userMenu, toggleUserMenu] = useToggle(false); const ref = useRef(null); - const themeReady = useSelector( - (state: RootState) => state.darkMode.systemTheme !== 'not-ready', - ); const onOutsideClick = useCallback( (e: React.MouseEvent) => { @@ -34,6 +43,16 @@ function Header(props: MainHeaderProps) { [toggleUserMenu], ); + const onClickNotification = (event: React.MouseEvent) => { + if (!user) { + event.preventDefault(); + dispatch(showAuthModal('LOGIN')); + return; + } + refetch(); + }; + + const notificationCount = notificationCountData?.notificationCount ?? 0; const urlForSearch = customHeader.custom ? `/search?username=${customHeader.username}` : '/search'; @@ -46,44 +65,48 @@ function Header(props: MainHeaderProps) { userLogo={customHeader.userLogo} username={customHeader.username} /> + + + {user && notificationCount !== 0 && ( + + {Math.min(99, notificationCount)} + + )} + + + + + + {user ? ( + <> + + 새 글 작성 + - {user ? ( - - {themeReady && } - - - - - 새 글 작성 - - -
- -
- -
- ) : ( - - {themeReady && } - - - +
+ +
+ + + ) : ( 로그인 -
- )} + )} +
); @@ -114,12 +137,53 @@ const SearchButton = styled(Link)` background: ${themedPalette.slight_layer}; } svg { - width: 1.125rem; - height: 1.125rem; + width: 24px; + height: 24px; } margin-right: 0.5rem; `; +const NotificationButton = styled(VLink)` + position: relative; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + width: 2.5rem; + height: 2.5rem; + outline: none; + border-radius: 50%; + color: ${themedPalette.text1}; + cursor: pointer; + margin-right: 4px; + &:hover { + background: ${themedPalette.slight_layer}; + } + svg { + width: 24px; + height: 24px; + } +`; + +const NotificationCounter = styled.div<{ isSingle: boolean }>` + position: absolute; + top: 3px; + right: -3px; + padding: 1px 4px; + font-weight: 500; + font-size: 11px; + background-color: var(--primary1); + color: var(--button-text); + border-radius: 100px; + + ${(props) => + props.isSingle && + css` + right: 4px; + `} +`; + const Inner = styled(MainResponsive)` height: 100%; display: flex; diff --git a/src/components/base/HeaderLogo.tsx b/src/components/base/HeaderLogo.tsx index b05e265b..7e7e29ed 100644 --- a/src/components/base/HeaderLogo.tsx +++ b/src/components/base/HeaderLogo.tsx @@ -1,12 +1,12 @@ import * as React from 'react'; import styled from 'styled-components'; -import { Link } from 'react-router-dom'; import { Logo, VelogIcon } from '../../static/svg'; import { UserLogo } from '../../modules/header'; import { themedPalette } from '../../lib/styles/themes'; import { createFallbackTitle } from '../../lib/utils'; import media from '../../lib/styles/media'; import { ellipsis } from '../../lib/styles/utils'; +import VLink from '../common/VLink'; export interface HeaderLogoProps { custom: boolean; @@ -22,23 +22,23 @@ const HeaderLogo: React.FC = ({ if (!custom) { return ( - + - + ); } if (!userLogo) return
; if (!username) return
; - const velogPath = `/@${username}`; + const velogPath = `/@${username}/posts`; return ( - - {userLogo.title || createFallbackTitle(username)} - + + {userLogo.title || createFallbackTitle(username)} + ); }; @@ -69,12 +69,12 @@ const HeaderLogoBlock = styled.div` .user-logo { display: block; - max-width: calc(100vw - 200px); + max-width: calc(100vw - 250px); ${ellipsis}; } `; -const VelogLogoLink = styled(Link)` +const VelogLogoLink = styled(VLink)` color: inherit; svg { diff --git a/src/components/base/HeaderUserMenu.tsx b/src/components/base/HeaderUserMenu.tsx index 09c1138f..b923b41f 100644 --- a/src/components/base/HeaderUserMenu.tsx +++ b/src/components/base/HeaderUserMenu.tsx @@ -44,7 +44,7 @@ const HeaderUserMenu: React.FC = ({
- + 내 벨로그
@@ -52,7 +52,9 @@ const HeaderUserMenu: React.FC = ({
임시 글 읽기 목록 - 설정 + + 설정 + 로그아웃
diff --git a/src/components/base/HeaderUserMenuItem.tsx b/src/components/base/HeaderUserMenuItem.tsx index 5c0412f0..87ffd09c 100644 --- a/src/components/base/HeaderUserMenuItem.tsx +++ b/src/components/base/HeaderUserMenuItem.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import styled from 'styled-components'; import { Link } from 'react-router-dom'; import { themedPalette } from '../../lib/styles/themes'; +import VLink from '../common/VLink'; const WrapperLink = styled(Link)` display: block; @@ -9,6 +10,12 @@ const WrapperLink = styled(Link)` text-decoration: none; `; +const WrapperVLink = styled(VLink)` + display: block; + color: inherit; + text-decoration: none; +`; + const HeaderUserMenuItemBlock = styled.div` color: ${themedPalette.text1}; padding: 0.75rem 1rem; @@ -24,25 +31,38 @@ const HeaderUserMenuItemBlock = styled.div` interface HeaderUserMenuItemProps { to?: string; onClick?: () => void; + isMigrated?: boolean; } const HeaderUserMenuItem: React.FC = ({ children, to, onClick, + isMigrated = false, }) => { const jsx = ( {children} ); - return to ? ( - - {jsx} - - ) : ( - jsx - ); + + if (to && !isMigrated) { + return ( + + {jsx} + + ); + } + + if (to && isMigrated) { + return ( + + {jsx} + + ); + } + + return jsx; }; export default HeaderUserMenuItem; diff --git a/src/components/common/AdFeed.tsx b/src/components/common/AdFeed.tsx index 7e40b56b..98f3fe11 100644 --- a/src/components/common/AdFeed.tsx +++ b/src/components/common/AdFeed.tsx @@ -96,9 +96,9 @@ function AdFeed({ forPost, index }: { forPost?: boolean; index: number }) { className="adsbygoogle" style={{ display: 'block' }} data-ad-format="fluid" - data-ad-layout-key="-6u+e7+18-4k+8t" + data-ad-layout-key="-6u+e5+1a-3q+77" data-ad-client="ca-pub-5574866530496701" - data-ad-slot="3828701581" + data-ad-slot="8480422066" > )} {/* {isMobile ? ( diff --git a/src/components/common/FlatPostCard.tsx b/src/components/common/FlatPostCard.tsx index aac2c75c..77db98bb 100644 --- a/src/components/common/FlatPostCard.tsx +++ b/src/components/common/FlatPostCard.tsx @@ -14,6 +14,7 @@ import media from '../../lib/styles/media'; import PrivatePostLabel from './PrivatePostLabel'; import optimizeImage from '../../lib/optimizeImage'; import { LikeIcon } from '../../static/svg'; +import VLink from './VLink'; const PostCardBlock = styled.div` padding-top: 4rem; @@ -150,7 +151,7 @@ const FlatPostCard = ({ post, hideUser }: PostCardProps) => { }; const url = `/@${post.user.username}/${post.url_slug}`; - const velogUrl = `/@${post.user.username}`; + const velogUrl = `/@${post.user.username}/posts`; if (!post.user.profile) { console.log(post); @@ -159,7 +160,7 @@ const FlatPostCard = ({ post, hideUser }: PostCardProps) => { {!hideUser && (
- + { )} alt="thumbnail" /> - +
- {post.user.username} + + {post.user?.profile?.display_name ?? post.user.username} +
)} diff --git a/src/components/common/FollowButton.tsx b/src/components/common/FollowButton.tsx new file mode 100644 index 00000000..37230ed9 --- /dev/null +++ b/src/components/common/FollowButton.tsx @@ -0,0 +1,205 @@ +import * as React from 'react'; +import styled, { css } from 'styled-components'; +import media from '../../lib/styles/media'; +import { debounce } from 'throttle-debounce'; +import { useApolloClient, useMutation } from '@apollo/react-hooks'; +import { FOLLOW_USER, UNFOLLOW_USER } from '../../lib/graphql/user'; +import { gql } from 'apollo-boost'; +import useUser from '../../lib/hooks/useUser'; +import { toast } from 'react-toastify'; +import { themedPalette } from '../../lib/styles/themes'; + +export interface PostFollowButtonProps { + followingUserId: string; + followed: boolean | undefined; +} + +const FollowButton: React.FC = ({ + followingUserId, + followed, +}) => { + const client = useApolloClient(); + const currentUser = useUser(); + const [follow, { loading: loadingFollowUser }] = useMutation(FOLLOW_USER); + const [unfollow, { loading: loadingUnfollowUser }] = + useMutation(UNFOLLOW_USER); + + const [initialFollowState, setInitialFollowState] = React.useState( + !!followed, + ); + const [currentFollowState, setCurrentFollowState] = React.useState( + !!followed, + ); + + const [buttonText, setButtonText] = React.useState('팔로잉'); + + const onFollowButtonMouseLeave = () => { + setInitialFollowState(currentFollowState || false); + }; + + const onUnfollowButtonMouseEnter = () => { + setButtonText('언팔로우'); + }; + + const onUnfollowButtonMouseLeave = () => { + setButtonText('팔로잉'); + }; + + const onClick = debounce(300, () => { + if (loadingFollowUser || loadingUnfollowUser) return; + + const variables = { + following_user_id: followingUserId, + }; + + const followFragment = gql` + fragment user on User { + is_followed + } + `; + + try { + if (!currentUser) { + toast.error('로그인 후 이용해주세요.'); + return; + } + + if (currentFollowState) { + client.writeFragment({ + id: `User:${followingUserId}`, + fragment: followFragment, + data: { + is_followed: false, + __typename: 'User', + }, + }); + unfollow({ variables }); + } else { + client.writeFragment({ + id: `User:${followingUserId}`, + fragment: followFragment, + data: { + is_followed: true, + __typename: 'User', + }, + }); + + follow({ variables }); + setButtonText('팔로잉'); + } + + setInitialFollowState(!currentFollowState); + setCurrentFollowState(!currentFollowState); + } catch (error) { + console.log('handle follow state error', error); + } + }); + + React.useEffect(() => { + if (followed === undefined) return; + setInitialFollowState(followed); + setCurrentFollowState(followed); + }, [followed]); + + if (followed === undefined) return null; + + return ( + + {!initialFollowState ? ( + + ) : ( + + )} + + ); +}; + +const FollowButtonBlock = styled.div<{ + followed: boolean; + unfollowed: boolean; +}>` + width: 96px; + height: 32px; + font-size: 16px; + + ${media.medium} { + width: 80px; + height: 24px; + font-size: 14px; + } + + ${media.custom(425)} { + width: 72px; + font-size: 12px; + } + + .button { + display: flex; + box-shadow: none; + align-items: center; + justify-content: center; + background-color: ${themedPalette.bg_element1}; + cursor: pointer; + border-radius: 16px; + font-weight: 700; + width: 100%; + height: 100%; + white-space: nowrap; + outline: none; + font-size: 16px; + + ${media.small} { + font-size: 14px; + } + + ${media.custom(425)} { + font-size: 12px; + } + } + + .follow-button { + color: ${themedPalette.primary1}; + border: 1px solid ${themedPalette.primary1}; + + ${(props) => + props.followed && + css` + color: ${themedPalette.bg_element6}; + border: 1px solid ${themedPalette.bg_element6}; + `} + } + + .unfollow-button { + color: ${themedPalette.bg_element6}; + border: 1px solid ${themedPalette.bg_element6}; + + ${(props) => + props.unfollowed && + css` + &:hover, + &:active { + color: ${themedPalette.destructive1}; + border: 1px solid ${themedPalette.destructive1}; + } + `} + } +`; + +export default FollowButton; diff --git a/src/components/common/LabelInput.tsx b/src/components/common/LabelInput.tsx index 38541dce..606564a3 100644 --- a/src/components/common/LabelInput.tsx +++ b/src/components/common/LabelInput.tsx @@ -61,7 +61,7 @@ const LabelInputBlock = styled.div<{ focus: boolean }>` border-color: ${themedPalette.primary1}; `} input { - width: 1; + width: 100%; } svg { font-size: 1.5rem; diff --git a/src/components/common/MarkdownRender.tsx b/src/components/common/MarkdownRender.tsx index 6abde08d..febe9ecc 100644 --- a/src/components/common/MarkdownRender.tsx +++ b/src/components/common/MarkdownRender.tsx @@ -30,6 +30,10 @@ export interface MarkdownRenderProps { editing?: boolean; } +function sanitizeEventScript(htmlString: string) { + return htmlString.replace(/ on\w+="[^"]*"/g, ''); +} + const MarkdownRenderBlock = styled.div` &.atom-one { ${prismThemes['atom-one']} @@ -123,7 +127,8 @@ const MarkdownRenderBlock = styled.div` `; function filter(html: string) { - return sanitize(html, { + const presanitized = sanitizeEventScript(html); + return sanitize(presanitized, { allowedTags: [ 'h1', 'h2', @@ -165,7 +170,7 @@ function filter(html: string) { ], allowedAttributes: { a: ['href', 'name', 'target'], - img: ['src'], + img: ['src', 'alt', 'width', 'height'], iframe: ['src', 'allow', 'allowfullscreen', 'scrolling', 'class'], '*': ['class', 'id', 'aria-hidden'], span: ['style'], diff --git a/src/components/common/PostCard.tsx b/src/components/common/PostCard.tsx index f73f7660..1befe891 100644 --- a/src/components/common/PostCard.tsx +++ b/src/components/common/PostCard.tsx @@ -14,6 +14,7 @@ import { mediaQuery } from '../../lib/styles/media'; import { Link } from 'react-router-dom'; import usePrefetchPost from '../../lib/hooks/usePrefetchPost'; import gtag from '../../lib/gtag'; +import VLink from './VLink'; export type PostCardProps = { post: PartialPost; @@ -73,7 +74,7 @@ function PostCard({ post, forHome, forPost }: PostCardProps) {