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 589fc0af..034cf768 100644
--- a/.env
+++ b/.env.production
@@ -1,4 +1,7 @@
-REACT_APP_API_HOST=http://localhost:5000/
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 5250d5b7..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,28 +11,34 @@ 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://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/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 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/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 f4b462f6..12e0201c 100644
--- a/src/components/auth/AuthForm.tsx
+++ b/src/components/auth/AuthForm.tsx
@@ -56,12 +56,12 @@ const AuthFormBlock = styled.div`
}
`;
-const Warning = styled.div`
- margin-top: 1rem;
- margin-bottom: 1rem;
- font-size: 0.875rem;
- color: ${themedPalette.text3};
-`;
+// const Warning = styled.div`
+// margin-top: 1rem;
+// margin-bottom: 1rem;
+// font-size: 0.875rem;
+// color: ${themedPalette.text3};
+// `;
export interface AuthFormProps {
mode: AuthMode;
@@ -70,6 +70,8 @@ export interface AuthFormProps {
onSendAuthEmail: (email: string) => void;
registered: boolean | null;
currentPath: string;
+ isIntegrate?: boolean;
+ integrateState?: string;
}
const AuthForm: React.FC = ({
@@ -79,6 +81,8 @@ const AuthForm: React.FC = ({
loading,
registered,
currentPath,
+ isIntegrate,
+ integrateState,
}) => {
const [email, onChangeEmail] = useInput('');
const onSubmit = (email: string) => {
@@ -94,7 +98,10 @@ const AuthForm: React.FC = ({
이메일로 {modeText}
{registered !== null ? (
-
+
) : (
= ({
-
-
- {mode === 'LOGIN'
- ? '아직 회원이 아니신가요?'
- : '계정이 이미 있으신가요?'}
-
-
- {mode === 'LOGIN' ? '회원가입' : '로그인'}
+ {isIntegrate ? null : (
+
+
+ {mode === 'LOGIN'
+ ? '아직 회원이 아니신가요?'
+ : '계정이 이미 있으신가요?'}
+
+
+ {mode === 'LOGIN' ? '회원가입' : '로그인'}
+
-
+ )}
);
};
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 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 f9aefc89..98f3fe11 100644
--- a/src/components/common/AdFeed.tsx
+++ b/src/components/common/AdFeed.tsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useRef, useState } from 'react';
+import React, { useEffect, useRef } from 'react';
import styled from 'styled-components';
import { mediaQuery } from '../../lib/styles/media';
import gtag from '../../lib/gtag';
@@ -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 4c83d532..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'],
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) {