diff --git a/.env b/.env deleted file mode 100644 index 90607079..00000000 --- a/.env +++ /dev/null @@ -1,2 +0,0 @@ -REACT_APP_API_HOST=http://localhost:5000/ -PUBLIC_URL=/ \ No newline at end of file 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.production b/.env.production new file mode 100644 index 00000000..034cf768 --- /dev/null +++ b/.env.production @@ -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=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 78c600ea..660d8f68 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,4 +1,4 @@ -name: Deploy Serverless SSR +name: Deploy on: push: @@ -11,56 +11,42 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@master + - name: Setup Node.js 16.x + uses: actions/setup-node@v2 + with: + node-version: '16.x' - name: Get yarn cache 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://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: s3 sync - uses: jakejarvis/s3-sync-action@master - with: - args: --follow-symlinks --delete + - name: upload env: - AWS_S3_BUCKET: ${{ secrets.AWS_S3_BUCKET }} + S3_BUCKET: ${{ secrets.AWS_S3_BUCKET }} + S3_BUCKET_SSR: ${{ secrets.AWS_S3_BUCKET_SSR }} AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_KEY }} AWS_REGION: 'ap-northeast-2' - SOURCE_DIR: 'build' - - name: s3 sync:ssr - uses: jakejarvis/s3-sync-action@master - with: - args: --follow-symlinks --delete - env: - AWS_S3_BUCKET: ${{ secrets.AWS_S3_BUCKET_SSR }} - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_KEY }} - AWS_REGION: 'ap-northeast-2' - SOURCE_DIR: 'dist' - - name: Deploy Completion Webhook - uses: joelwmale/webhook-action@master - env: - WEBHOOK_URL: ${{ secrets.SSR_COMPLETE_WEBHOOK }} - data: "{'deployment': 'finished'}" - - name: Slack Notification - uses: rtCamp/action-slack-notify@v2.0.0 - env: - SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} - SLACK_USERNAME: 'Github Actions' - SLACK_ICON: 'https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png' - SLACK_MESSAGE: '*stage: production* - 벨로그 웹 클라이언트 배포가 끝났어요! :rocket:' \ No newline at end of file + run: | + yarn upload + yarn upload:ssr \ No newline at end of file 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/config/webpack.config.server.js b/config/webpack.config.server.js index 9d975f25..2728133f 100644 --- a/config/webpack.config.server.js +++ b/config/webpack.config.server.js @@ -130,8 +130,8 @@ module.exports = { resolve: { modules: ['node_modules'], extensions: paths.moduleFileExtensions - .map(ext => `.${ext}`) - .filter(ext => true || !ext.includes('ts')), + .map((ext) => `.${ext}`) + .filter((ext) => true || !ext.includes('ts')), }, plugins: [ new webpack.DefinePlugin(env.stringified), @@ -139,6 +139,10 @@ module.exports = { /codemirror/, path.resolve(paths.appSrc, 'lib/replacedModule.ts'), ), + new webpack.NormalModuleReplacementPlugin( + /lib\/graphql\/client/, + path.resolve(paths.appSrc, 'lib/replacedModule.ts'), + ), new webpack.optimize.LimitChunkCountPlugin({ maxChunks: 1 }), ], optimization: { diff --git a/package.json b/package.json index 8196898d..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,14 +163,14 @@ "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/keepChunks.js && node scripts/build.server.js", + "build:ci": "node scripts/build.js && node scripts/build.server.js", "build:server": "node scripts/build.server.js", "start:server:local": "node ./dist/server.js", "test": "node scripts/test.js", - "upload:s3": "aws s3 sync ./build s3://$S3_BUCKET --delete", - "upload:ssr": "aws s3 sync ./build s3://$S3_BUCKET_SSR", + "upload": "aws s3 cp --recursive ./build s3://$S3_BUCKET", + "upload:ssr": "aws s3 cp --recursive ./dist s3://$S3_BUCKET_SSR", "download": "aws s3 cp s3://$S3_BUCKET ./build/ --recursive && aws s3 cp s3://$S3_BUCKET_SSR ./dist/ --recursive", "deploy": "pm2 reload deploy.config.json" }, 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/scripts/keepChunks.js b/scripts/keepChunks.js index 6de30c6e..bc027aad 100644 --- a/scripts/keepChunks.js +++ b/scripts/keepChunks.js @@ -71,7 +71,7 @@ function filterOutMapFiles(files) { */ function downloadUrls(urls) { return Promise.all( - urls.map(url => { + urls.map((url) => { const downloadPath = url .slice(0, url.lastIndexOf('/')) .replace(PUBLIC_URL, ''); @@ -95,13 +95,15 @@ async function keepChunks() { .reduce((acc, current) => { return acc.concat(Object.values(current.files)); }, []) - .filter(url => + .filter((url) => ['index.html', 'loadable-stats.json', 'service-worker.js'].every( - ignored => !url.includes(ignored), + (ignored) => !url.includes(ignored), ), ); - await downloadUrls(urls); + try { + await downloadUrls(urls); + } catch (e) {} // update assetHistory assetHistory.history.push({ diff --git a/scripts/start.js b/scripts/start.js index dd89084f..c2f83ad3 100644 --- a/scripts/start.js +++ b/scripts/start.js @@ -1,5 +1,3 @@ -'use strict'; - // Do this as the first thing so that any code reading it knows the right env. process.env.BABEL_ENV = 'development'; process.env.NODE_ENV = 'development'; @@ -7,14 +5,13 @@ process.env.NODE_ENV = 'development'; // Makes the script crash on unhandled rejections instead of silently // ignoring them. In the future, promise rejections that are not handled will // terminate the Node.js process with a non-zero exit code. -process.on('unhandledRejection', err => { +process.on('unhandledRejection', (err) => { throw err; }); // Ensure environment variables are read. require('../config/env'); - const fs = require('fs'); const chalk = require('react-dev-utils/chalk'); const webpack = require('webpack'); @@ -48,15 +45,15 @@ if (process.env.HOST) { console.log( chalk.cyan( `Attempting to bind to HOST environment variable: ${chalk.yellow( - chalk.bold(process.env.HOST) - )}` - ) + chalk.bold(process.env.HOST), + )}`, + ), ); console.log( - `If this was unintentional, check that you haven't mistakenly set it in your shell.` + `If this was unintentional, check that you haven't mistakenly set it in your shell.`, ); console.log( - `Learn more here: ${chalk.yellow('https://bit.ly/CRA-advanced-config')}` + `Learn more here: ${chalk.yellow('https://bit.ly/CRA-advanced-config')}`, ); console.log(); } @@ -70,7 +67,7 @@ checkBrowsers(paths.appPath, isInteractive) // run on a different port. `choosePort()` Promise resolves to the next free port. return choosePort(HOST, DEFAULT_PORT); }) - .then(port => { + .then((port) => { if (port == null) { // We have not found a port. return; @@ -82,9 +79,9 @@ checkBrowsers(paths.appPath, isInteractive) const tscCompileOnError = process.env.TSC_COMPILE_ON_ERROR === 'true'; const urls = prepareUrls(protocol, HOST, port); const devSocket = { - warnings: warnings => + warnings: (warnings) => devServer.sockWrite(devServer.sockets, 'warnings', warnings), - errors: errors => + errors: (errors) => devServer.sockWrite(devServer.sockets, 'errors', errors), }; // Create a webpack compiler that is configured with custom messages. @@ -99,16 +96,19 @@ checkBrowsers(paths.appPath, isInteractive) webpack, }); // Load proxy config - const proxySetting = require(paths.appPackageJson).proxy; + const proxySetting = + process.env.NODE_ENV !== 'production' + ? process.env.REACT_APP_API_HOST + : require(paths.appPackageJson).proxy; const proxyConfig = prepareProxy(proxySetting, paths.appPublic); // Serve webpack assets generated by the compiler over a web server. const serverConfig = createDevServerConfig( proxyConfig, - urls.lanUrlForConfig + urls.lanUrlForConfig, ); const devServer = new WebpackDevServer(compiler, serverConfig); // Launch WebpackDevServer. - devServer.listen(port, HOST, err => { + devServer.listen(port, HOST, (err) => { if (err) { return console.log(err); } @@ -122,8 +122,8 @@ checkBrowsers(paths.appPath, isInteractive) if (process.env.NODE_PATH) { console.log( chalk.yellow( - 'Setting NODE_PATH to resolve modules absolutely has been deprecated in favor of setting baseUrl in jsconfig.json (or tsconfig.json if you are using TypeScript) and will be removed in a future major release of create-react-app.' - ) + 'Setting NODE_PATH to resolve modules absolutely has been deprecated in favor of setting baseUrl in jsconfig.json (or tsconfig.json if you are using TypeScript) and will be removed in a future major release of create-react-app.', + ), ); console.log(); } @@ -132,14 +132,14 @@ checkBrowsers(paths.appPath, isInteractive) openBrowser(urls.localUrlForBrowser); }); - ['SIGINT', 'SIGTERM'].forEach(function(sig) { - process.on(sig, function() { + ['SIGINT', 'SIGTERM'].forEach(function (sig) { + process.on(sig, function () { devServer.close(); process.exit(); }); }); }) - .catch(err => { + .catch((err) => { if (err && err.message) { console.log(err.message); } diff --git a/src/App.tsx b/src/App.tsx index c36e5e33..bfc35baa 100755 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,6 +14,7 @@ import { Helmet } from 'react-helmet-async'; import HomePage from './pages/home/HomePage'; import MainPageTemplate from './components/main/MainPageTemplate'; import ConditionalBackground from './components/base/ConditionalBackground'; +import UserIntegratePage from './pages/UserIntegratePage'; const loadableConfig = { fallback: , @@ -27,6 +28,11 @@ const EmailLoginPage = loadable( () => import('./pages/EmailLoginPage'), loadableConfig, ); +const EmailChangePage = loadable( + () => import('./pages/EmailChangePage'), + loadableConfig, +); + const WritePage = loadable(() => import('./pages/WritePage')); const SearchPage = loadable(() => import('./pages/SearchPage'), loadableConfig); const SavesPage = loadable(() => import('./pages/SavesPage'), loadableConfig); @@ -76,6 +82,7 @@ const App: React.FC = (props) => { {/* */} + @@ -86,6 +93,7 @@ const App: React.FC = (props) => { } /> + diff --git a/src/components/auth/AuthEmailSuccess.tsx b/src/components/auth/AuthEmailSuccess.tsx index f5fb5ab8..67b27c27 100644 --- a/src/components/auth/AuthEmailSuccess.tsx +++ b/src/components/auth/AuthEmailSuccess.tsx @@ -1,9 +1,9 @@ import * as React from 'react'; -import styled from 'styled-components'; +import styled, { css } from 'styled-components'; import { MdCheck } from 'react-icons/md'; import palette from '../../lib/styles/palette'; -const AuthEmailSuccessBlock = styled.div` +const AuthEmailSuccessBlock = styled.div<{ isIntegrate?: boolean }>` display: flex; align-items: center; background: ${palette.teal1}; @@ -12,6 +12,7 @@ const AuthEmailSuccessBlock = styled.div` padding-right: 0.75rem; height: 3rem; color: ${palette.teal9}; + white-space: pre; .icon { font-size: 1.5rem; } @@ -20,18 +21,58 @@ const AuthEmailSuccessBlock = styled.div` flex: 1; text-align: center; } + + ${(props) => + props.isIntegrate && + css` + height: 4rem; + `} +`; + +const FakeLink = styled.button` + display: inline; + cursor: pointer; + text-decoration: underline; + font-weight: 600; + background: none; + padding: 0; + border: none; + outline: none; + color: ${palette.teal9}; + &:hover { + color: ${palette.teal7}; + } `; export interface AuthEmailSuccessProps { registered: boolean; + isIntegrate?: boolean; } -const AuthEmailSuccess: React.FC = ({ registered }) => { +const AuthEmailSuccess: React.FC = ({ + registered, + isIntegrate, +}) => { const text = registered ? '로그인' : '회원가입'; return ( - + -
{text} 링크가 이메일로 전송되었습니다.
+
+ {text} 링크가 이메일로 전송되었습니다. + {isIntegrate ? ( + <> + {'\n'}로그인 후 이 창에서{' '} + { + window.location.reload(); + }} + > + 새로고침 + + 을 해주세요. + + ) : null} +
); }; diff --git a/src/components/auth/AuthForm.tsx b/src/components/auth/AuthForm.tsx index 8287d778..12e0201c 100644 --- a/src/components/auth/AuthForm.tsx +++ b/src/components/auth/AuthForm.tsx @@ -56,6 +56,13 @@ const AuthFormBlock = styled.div` } `; +// const Warning = styled.div` +// margin-top: 1rem; +// margin-bottom: 1rem; +// font-size: 0.875rem; +// color: ${themedPalette.text3}; +// `; + export interface AuthFormProps { mode: AuthMode; loading: boolean; @@ -63,6 +70,8 @@ export interface AuthFormProps { onSendAuthEmail: (email: string) => void; registered: boolean | null; currentPath: string; + isIntegrate?: boolean; + integrateState?: string; } const AuthForm: React.FC = ({ @@ -72,6 +81,8 @@ const AuthForm: React.FC = ({ loading, registered, currentPath, + isIntegrate, + integrateState, }) => { const [email, onChangeEmail] = useInput(''); const onSubmit = (email: string) => { @@ -87,7 +98,10 @@ const AuthForm: React.FC = ({

이메일로 {modeText}

{registered !== null ? ( - + ) : ( = ({

소셜 계정으로 {modeText}

- +
-
- - {mode === 'LOGIN' - ? '아직 회원이 아니신가요?' - : '계정이 이미 있으신가요?'} - -
- {mode === 'LOGIN' ? '회원가입' : '로그인'} + {isIntegrate ? null : ( +
+ + {mode === 'LOGIN' + ? '아직 회원이 아니신가요?' + : '계정이 이미 있으신가요?'} + +
+ {mode === 'LOGIN' ? '회원가입' : '로그인'} +
-
+ )} ); }; diff --git a/src/components/auth/AuthModal.tsx b/src/components/auth/AuthModal.tsx index 35b22150..2ad87485 100644 --- a/src/components/auth/AuthModal.tsx +++ b/src/components/auth/AuthModal.tsx @@ -21,7 +21,7 @@ const AuthModalBlock = styled.div<{ visible: boolean }>` z-index: ${zIndexes.AuthModal}; .wrapper { width: 606px; - height: 480px; + height: 530px; ${media.small} { flex: 1; width: auto; diff --git a/src/components/auth/AuthSocialButton.tsx b/src/components/auth/AuthSocialButton.tsx index 54f05179..e5ae4510 100644 --- a/src/components/auth/AuthSocialButton.tsx +++ b/src/components/auth/AuthSocialButton.tsx @@ -27,6 +27,8 @@ interface AuthSocialButtonProps { provider: 'facebook' | 'google' | 'github'; tabIndex?: number; currentPath: string; + isIntegrate?: boolean; + integrateState?: string; } const providerMap = { @@ -51,6 +53,8 @@ const AuthSocialButton: React.FC = ({ provider, tabIndex, currentPath, + isIntegrate, + integrateState, }) => { const info = providerMap[provider]; const { icon: Icon, color, border } = info; @@ -58,9 +62,11 @@ const AuthSocialButton: React.FC = ({ const host = process.env.NODE_ENV === 'production' ? process.env.REACT_APP_API_HOST - : 'http://localhost:5000/'; + : 'http://localhost:5002/'; - const redirectTo = `${host}api/v2/auth/social/redirect/${provider}?next=${currentPath}`; + const redirectTo = `${host}api/v2/auth/social/redirect/${provider}?next=${currentPath}&isIntegrate=${ + isIntegrate ? 1 : 0 + }${integrateState ? `&integrateState=${integrateState}` : ''}`; return ( { +const AuthSocialButtonGroup = ({ + currentPath, + isIntegrate, + integrateState, +}: { + currentPath: string; + isIntegrate?: boolean; + integrateState?: string; +}) => { return ( ); 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 fd85a152..b923b41f 100644 --- a/src/components/base/HeaderUserMenu.tsx +++ b/src/components/base/HeaderUserMenu.tsx @@ -27,7 +27,7 @@ const HeaderUserMenuBlock = styled.div` `; interface HeaderUserMenuProps { - onClose: (e: React.MouseEvent) => void; + onClose: (e: React.MouseEvent) => void; onLogout: () => void; username: string; visible: boolean; @@ -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/base/hooks/useHeader.ts b/src/components/base/hooks/useHeader.ts index b51e554c..e7b47b41 100644 --- a/src/components/base/hooks/useHeader.ts +++ b/src/components/base/hooks/useHeader.ts @@ -4,11 +4,14 @@ import { showAuthModal } from '../../../modules/core'; import { RootState } from '../../../modules'; import { logout } from '../../../lib/api/auth'; import storage from '../../../lib/storage'; +import { useMutation } from '@apollo/react-hooks'; +import { LOGOUT } from '../../../lib/graphql/user'; export default function useHeader() { const dispatch = useDispatch(); const user = useSelector((state: RootState) => state.core.user); const customHeader = useSelector((state: RootState) => state.header); + const [graphqlLogout] = useMutation(LOGOUT); const onLoginClick = useCallback(() => { dispatch(showAuthModal('LOGIN')); @@ -16,11 +19,11 @@ export default function useHeader() { const onLogout = useCallback(async () => { try { - await logout(); + await Promise.all([logout(), graphqlLogout()]); } catch {} storage.removeItem('CURRENT_USER'); window.location.href = '/'; - }, []); + }, [graphqlLogout]); return { user, onLoginClick, onLogout, customHeader }; } 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 a0dc0ab6..77db98bb 100644 --- a/src/components/common/FlatPostCard.tsx +++ b/src/components/common/FlatPostCard.tsx @@ -13,6 +13,8 @@ import RatioImage from './RatioImage'; 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; @@ -106,6 +108,16 @@ const PostCardBlock = styled.div` margin-left: 0.5rem; margin-right: 0.5rem; } + + .likes { + display: flex; + align-items: center; + svg { + width: 0.875rem; + height: 0.875rem; + margin-right: 0.25rem; + } + } } .tags-wrapper { margin-bottom: -0.875rem; @@ -139,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); @@ -148,7 +160,7 @@ const FlatPostCard = ({ post, hideUser }: PostCardProps) => { {!hideUser && (
- + { )} alt="thumbnail" /> - +
- {post.user.username} + + {post.user?.profile?.display_name ?? post.user.username} +
)} @@ -186,6 +200,11 @@ const FlatPostCard = ({ post, hideUser }: PostCardProps) => { {formatDate(post.released_at)}
·
{post.comments_count}개의 댓글 +
·
+ + + {post.likes} + {post.is_private && ( <>
·
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 bac1c7a8..febe9ecc 100644 --- a/src/components/common/MarkdownRender.tsx +++ b/src/components/common/MarkdownRender.tsx @@ -13,7 +13,6 @@ import media from '../../lib/styles/media'; import parse from 'html-react-parser'; import { throttle } from 'throttle-debounce'; import sanitize from 'sanitize-html'; -import palette from '../../lib/styles/palette'; import math from 'remark-math'; import remark2rehype from 'remark-rehype'; import katex from 'rehype-katex'; @@ -31,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']} @@ -124,7 +127,8 @@ const MarkdownRenderBlock = styled.div` `; function filter(html: string) { - return sanitize(html, { + const presanitized = sanitizeEventScript(html); + return sanitize(presanitized, { allowedTags: [ 'h1', 'h2', @@ -166,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'], @@ -256,7 +260,7 @@ const MarkdownRender: React.FC = ({ const html = String(file); if (onConvertFinish) { - onConvertFinish(html); + onConvertFinish(filter(html)); } // load twitter script if needed if (html.indexOf('class="twitter-tweet"') !== -1) { 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) {