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/dev.yml b/.github/workflows/dev.yml index 3d0f76c5..2a95e2f9 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -28,7 +28,7 @@ jobs: - name: npm run build:ci run: npm run build:ci env: - REACT_APP_API_HOST: 'https://v2dev.velog.io/' + REACT_APP_API_HOST: 'https://v2.velog.io/' PUBLIC_URL: 'https://d3v0gm8v6v8olv.cloudfront.net/' STAGE: true - name: s3 sync @@ -50,7 +50,7 @@ jobs: # or if using AWS creds directly AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_KEY }} - REACT_APP_API_HOST: 'https://v2dev.velog.io/' + REACT_APP_API_HOST: 'https://v2.velog.io/' PUBLIC_URL: 'https://d3v0gm8v6v8olv.cloudfront.net/' STAGE: true - name: Slack Notification diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 62e9ebcf..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,55 +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-${{ hashFiles('**/yarn.lock') }} + 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/' - - name: s3 sync - uses: jakejarvis/s3-sync-action@master - with: - args: --follow-symlinks --delete + 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: - 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: serverless deploy - uses: serverless/github-action@master - with: - args: deploy --stage production - env: - # or if using AWS creds directly - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_KEY }} - REACT_APP_API_HOST: 'https://v2.velog.io/' - PUBLIC_URL: 'https://static.velog.io/' - - 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/deploy.config.json b/deploy.config.json new file mode 100644 index 00000000..343bbbf8 --- /dev/null +++ b/deploy.config.json @@ -0,0 +1,13 @@ +{ + "apps": [ + { + "name": "ssr", + "script": "./dist/server.js", + "instances": 2, + "exec_mode": "cluster", + "env": { + "NODE_PATH": "src" + } + } + ] +} \ No newline at end of file diff --git a/package.json b/package.json index f2744c47..eb77c765 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,10 @@ "@apollo/react-testing": "^3.1.3", "@babel/core": "7.7.4", "@koa/router": "^8.0.5", - "@loadable/babel-plugin": "^5.11.0", - "@loadable/component": "^5.11.0", - "@loadable/server": "^5.11.0", + "@loadable/babel-plugin": "^5.13.2", + "@loadable/component": "^5.15.0", + "@loadable/server": "^5.15.1", + "@react-spring/web": "^9.4.2", "@reduxjs/toolkit": "^1.2.2", "@sentry/browser": "^5.11.1", "@svgr/webpack": "4.3.3", @@ -25,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", @@ -46,10 +48,10 @@ "@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/styled-components": "^5.1.21", "@types/throttle-debounce": "^2.1.0", - "@typescript-eslint/eslint-plugin": "^2.8.0", - "@typescript-eslint/parser": "^2.8.0", + "@typescript-eslint/eslint-plugin": "^4.1.1", + "@typescript-eslint/parser": "^4.1.1", "apollo-boost": "^0.4.7", "apollo-link": "^1.2.13", "aws-lambda": "^1.0.4", @@ -90,10 +92,12 @@ "jest-environment-jsdom-fourteen": "0.1.0", "jest-resolve": "24.9.0", "jest-watch-typeahead": "0.4.2", + "just-detect-adblock": "^1.1.0", "katex": "^0.11.1", "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", @@ -118,7 +122,7 @@ "react-outside-click-handler": "^1.3.0", "react-redux": "^7.1.3", "react-router-dom": "^5.1.2", - "react-spring": "^8.0.27", + "react-spring": "^9.4.2", "react-textarea-autosize": "^7.1.2", "react-toastify": "^5.5.0", "react-use": "^13.12.2", @@ -129,7 +133,7 @@ "rehype-raw": "^4.0.2", "rehype-stringify": "^6.0.1", "remark": "^11.0.2", - "remark-breaks": "^1.0.3", + "remark-breaks": "^2.0.1", "remark-highlight.js": "^5.2.0", "remark-html": "^10.0.0", "remark-math": "^2.0.1", @@ -144,12 +148,12 @@ "snakecase-keys": "^3.1.0", "strip-markdown": "^3.1.1", "style-loader": "1.0.0", - "styled-components": "^4.4.1", + "styled-components": "^5.3.3", "terser-webpack-plugin": "2.2.1", "throttle-debounce": "^2.1.0", "ts-pnp": "1.1.5", "typesafe-actions": "^5.1.0", - "typescript": "~3.7.2", + "typescript": "^4.5.5", "unist-util-visit": "^2.0.1", "url-loader": "2.3.0", "webpack": "4.41.2", @@ -159,13 +163,16 @@ "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", + "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": "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" }, "eslintConfig": { "extends": [ @@ -243,8 +250,8 @@ "@loadable/babel-plugin" ] }, - "proxy": "http://localhost:5000/", + "proxy": "https://v2.velog.io/", "devDependencies": { - "@loadable/webpack-plugin": "^5.7.1" + "@loadable/webpack-plugin": "^5.15.1" } } diff --git a/public/index.html b/public/index.html index e681d654..d2a80421 100644 --- a/public/index.html +++ b/public/index.html @@ -29,15 +29,17 @@ 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 515f66f6..bfc35baa 100755 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,8 @@ import NotFoundPage from './pages/NotFoundPage'; 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: , @@ -26,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); @@ -46,9 +53,11 @@ const ReadingListPage = loadable( }, ); +const PostStatsPage = loadable(() => import('./pages/PostStatsPage')); + interface AppProps {} -const App: React.FC = props => { +const App: React.FC = (props) => { return ( @@ -60,6 +69,7 @@ const App: React.FC = props => { + @@ -72,6 +82,7 @@ const App: React.FC = props => { {/* */} + @@ -81,6 +92,8 @@ const App: React.FC = props => { } /> + + diff --git a/src/GlobalStyles.ts b/src/GlobalStyles.ts index 5fc987f9..f0ae4413 100644 --- a/src/GlobalStyles.ts +++ b/src/GlobalStyles.ts @@ -1,4 +1,5 @@ import { createGlobalStyle } from 'styled-components'; +import { themedPalette, themes } from './lib/styles/themes'; const GlobalStyles = createGlobalStyle` body { @@ -7,8 +8,9 @@ body { font-family: -apple-system, BlinkMacSystemFont, "Helvetica Neue", "Apple SD Gothic Neo", "Malgun Gothic", "맑은 고딕", 나눔고딕, "Nanum Gothic", "Noto Sans KR", "Noto Sans CJK KR", arial, 돋움, Dotum, Tahoma, Geneva, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - color: #212529; + color: ${themedPalette.text1}; box-sizing: border-box; + } * { @@ -27,6 +29,25 @@ input, button, textarea { html, body, #root { height: 100%; } + +body { + ${themes.light} +} + +@media (prefers-color-scheme: dark) { + body { + ${themes.dark} + } +} + +body[data-theme='light'] { + ${themes.light}; +} + +body[data-theme='dark'] { + ${themes.dark}; +} + `; export default GlobalStyles; diff --git a/src/components/auth/AuthEmailForm.tsx b/src/components/auth/AuthEmailForm.tsx index 87deb1f6..de557f4c 100644 --- a/src/components/auth/AuthEmailForm.tsx +++ b/src/components/auth/AuthEmailForm.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import styled from 'styled-components'; -import palette from '../../lib/styles/palette'; +import { themedPalette } from '../../lib/styles/themes'; const AuthEmailFormBlock = styled.form` width: 100%; @@ -12,18 +12,24 @@ const AuthEmailFormBlock = styled.form` border-bottom-left-radius: 2px; padding: 1rem; font-size: 1rem; - border: 1px solid ${palette.gray3}; + background: ${themedPalette.bg_element1}; + border: 1px solid ${themedPalette.border3}; + color: ${themedPalette.text1}; border-right: none; + outline: none; + &:focus { + border: 1px solid ${themedPalette.primary1}; + } &::placeholder { - color: ${palette.gray6}; + color: ${themedPalette.text3}; } &:disabled { - background: ${palette.gray1}; + background: ${themedPalette.bg_element2}; } } button { - background: ${palette.teal6}; - color: white; + background: ${themedPalette.primary1}; + color: ${themedPalette.button_text}; font-size: 1rem; font-weight: bold; outline: none; @@ -35,11 +41,11 @@ const AuthEmailFormBlock = styled.form` cursor: pointer; &:hover, &:focus { - background: ${palette.teal5}; + background: ${themedPalette.primary2}; } &:disabled { - background: ${palette.gray5}; - color: ${palette.gray3}; + background: ${themedPalette.border4}; + color: ${themedPalette.text4}; cursor: default; } } @@ -62,7 +68,7 @@ const AuthEmailForm: React.FC = ({ }) => { return ( { + onSubmit={(e) => { e.preventDefault(); onSubmit(value); }} diff --git a/src/components/auth/AuthEmailSuccess.tsx b/src/components/auth/AuthEmailSuccess.tsx index 624744c3..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}; @@ -11,7 +11,8 @@ const AuthEmailSuccessBlock = styled.div` padding-left: 0.75rem; padding-right: 0.75rem; height: 3rem; - color: ${palette.teal7}; + 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 7d38867e..12e0201c 100644 --- a/src/components/auth/AuthForm.tsx +++ b/src/components/auth/AuthForm.tsx @@ -3,7 +3,7 @@ import styled from 'styled-components'; import useInput from '../../lib/hooks/useInput'; import AuthEmailForm from './AuthEmailForm'; import { AuthMode } from '../../modules/core'; -import palette from '../../lib/styles/palette'; +import { themedPalette } from '../../lib/styles/themes'; import AuthSocialButtonGroup from './AuthSocialButtonGroup'; import AuthEmailSuccess from './AuthEmailSuccess'; import media from '../../lib/styles/media'; @@ -25,12 +25,12 @@ const AuthFormBlock = styled.div` } h2 { font-size: 1.3125rem; - color: ${palette.gray8}; + color: ${themedPalette.text1}; } h4 { margin-top: 1rem; margin-bottom: 1rem; - color: ${palette.gray6}; + color: ${themedPalette.text3}; } section + section { margin-top: 2.5rem; @@ -47,7 +47,7 @@ const AuthFormBlock = styled.div` .link { display: inline-block; font-weight: bold; - color: ${palette.teal6}; + color: ${themedPalette.primary1}; cursor: pointer; &:hover { text-decoration: underline; @@ -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 3c7128e6..2ad87485 100644 --- a/src/components/auth/AuthModal.tsx +++ b/src/components/auth/AuthModal.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import styled, { css } from 'styled-components'; import { MdClose } from 'react-icons/md'; import zIndexes from '../../lib/styles/zIndexes'; -import palette from '../../lib/styles/palette'; +import { themedPalette } from '../../lib/styles/themes'; import { undrawJoyride } from '../../static/images'; import transitions from '../../lib/styles/transitions'; import media from '../../lib/styles/media'; @@ -21,14 +21,14 @@ const AuthModalBlock = styled.div<{ visible: boolean }>` z-index: ${zIndexes.AuthModal}; .wrapper { width: 606px; - height: 480px; + height: 530px; ${media.small} { flex: 1; width: auto; height: 100%; } - ${props => + ${(props) => props.visible ? css` animation: ${transitions.popInFromBottom} 0.4s forwards ease-in-out; @@ -44,7 +44,7 @@ const AuthModalBlock = styled.div<{ visible: boolean }>` display: none; } width: 216px; - background: ${palette.gray1}; + background: ${themedPalette.bg_element2}; padding: 1.5rem; display: flex; flex-direction: column; @@ -58,14 +58,14 @@ const AuthModalBlock = styled.div<{ visible: boolean }>` .welcome { font-size: 1.75rem; margin-top: 1.5rem; - color: ${palette.gray7}; + color: ${themedPalette.text2}; text-align: center; font-weight: 600; } } .white-block { flex: 1; - background: white; + background: ${themedPalette.bg_page2}; padding: 1.5rem; display: flex; flex-direction: column; @@ -76,7 +76,7 @@ const AuthModalBlock = styled.div<{ visible: boolean }>` display: flex; justify-content: flex-end; font-size: 1.5rem; - color: ${palette.gray6}; + color: ${themedPalette.text3}; margin-bottom: 2.25rem; svg { cursor: pointer; @@ -106,7 +106,7 @@ const AuthModal: React.FC = ({ }) => { const [closed, setClosed] = useState(true); useEffect(() => { - let timeoutId: number | null = null; + let timeoutId: ReturnType | null = null; if (visible) { setClosed(false); } else { diff --git a/src/components/auth/AuthSocialButton.tsx b/src/components/auth/AuthSocialButton.tsx index f0d899e7..e5ae4510 100644 --- a/src/components/auth/AuthSocialButton.tsx +++ b/src/components/auth/AuthSocialButton.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import styled, { css } from 'styled-components'; import { FacebookIcon, GoogleIcon, GithubIcon } from '../../static/svg'; -import palette from '../../lib/styles/palette'; +import { themedPalette } from '../../lib/styles/themes'; const AuthSocialButtonBlock = styled.a<{ border: boolean }>` width: 3rem; @@ -13,10 +13,10 @@ const AuthSocialButtonBlock = styled.a<{ border: boolean }>` outline: none; transition: 0.125s all ease-in; color: white; - ${props => + ${(props) => props.border && css` - border: 1px solid ${palette.gray3}; + border: 1px solid ${themedPalette.border3}; `} &:focus { box-shadow: 0px 2px 12px rgba(0, 0, 0, 0.35); @@ -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/auth/__tests__/__snapshots__/AuthForm.test.tsx.snap b/src/components/auth/__tests__/__snapshots__/AuthForm.test.tsx.snap index a32040bd..ff539b60 100644 --- a/src/components/auth/__tests__/__snapshots__/AuthForm.test.tsx.snap +++ b/src/components/auth/__tests__/__snapshots__/AuthForm.test.tsx.snap @@ -3,7 +3,7 @@ exports[`AuthForm renders correctly 1`] = `
{ + setTimeout(() => { + setEnabled(true); + }, 500); + }, []); + + if (!enabled) return null; + return ; +} + +export default BodyTransition; diff --git a/src/components/base/ConditionalBackground.tsx b/src/components/base/ConditionalBackground.tsx new file mode 100644 index 00000000..4682d191 --- /dev/null +++ b/src/components/base/ConditionalBackground.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { useMemo } from 'react'; +import { matchPath, useLocation } from 'react-router-dom'; +import { createGlobalStyle } from 'styled-components'; +import { themedPalette } from '../../lib/styles/themes'; + +interface Props {} + +const GrayBackground = createGlobalStyle` + body { + background: ${themedPalette.bg_page1}; + } +`; + +const WhiteBackground = createGlobalStyle` + body { + background: ${themedPalette.bg_page2}; + } +`; + +/** + * Bacgkround should be gray on following paths + * - / + * - /recent + * - /lists + */ +function ConditionalBackground(props: Props) { + const location = useLocation(); + + const isGray = useMemo( + () => + [{ path: '/', exact: true }, '/recent', '/lists'].some((path) => + matchPath(location.pathname, path), + ), + [location], + ); + + return isGray ? : ; +} + +export default ConditionalBackground; diff --git a/src/components/base/FloatingHeader.tsx b/src/components/base/FloatingHeader.tsx index de29a41f..8649fb1b 100644 --- a/src/components/base/FloatingHeader.tsx +++ b/src/components/base/FloatingHeader.tsx @@ -6,6 +6,7 @@ import MainResponsive from '../main/MainResponsive'; import { getScrollTop } from '../../lib/utils'; import { Route } from 'react-router-dom'; import ReadingListTab from '../readingList/ReadingListTab'; +import { themedPalette } from '../../lib/styles/themes'; export type FloatingHeaderProps = {}; @@ -107,7 +108,7 @@ function FloatingHeader(props: FloatingHeaderProps) { const Block = styled.div` position: fixed; top: 0; - background: white; + background: ${themedPalette.bg_element1}; width: 100%; z-index: 10; diff --git a/src/components/base/Header.tsx b/src/components/base/Header.tsx index 729a4a44..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'; @@ -10,11 +10,27 @@ import HeaderUserMenu from './HeaderUserMenu'; import { Link } from 'react-router-dom'; import media from '../../lib/styles/media'; import HeaderLogo from './HeaderLogo'; +import { themedPalette } from '../../lib/styles/themes'; +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); @@ -27,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'; @@ -39,42 +65,48 @@ function Header(props: MainHeaderProps) { userLogo={customHeader.userLogo} username={customHeader.username} /> + + + {user && notificationCount !== 0 && ( + + {Math.min(99, notificationCount)} + + )} + + + + + + {user ? ( + <> + + 새 글 작성 + - {user ? ( - - - - - - 새 글 작성 - - -
- -
- -
- ) : ( - - - - +
+ +
+ + + ) : ( 로그인 -
- )} + )} +
); @@ -99,15 +131,57 @@ const SearchButton = styled(Link)` height: 2.5rem; outline: none; border-radius: 50%; + color: ${themedPalette.text1}; + cursor: pointer; + &:hover { + background: ${themedPalette.slight_layer}; + } + svg { + 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: rgba(0, 0, 0, 0.045); + background: ${themedPalette.slight_layer}; } svg { - width: 1.125rem; - height: 1.125rem; + width: 24px; + height: 24px; } - margin-right: 0.75rem; +`; + +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)` diff --git a/src/components/base/HeaderLogo.tsx b/src/components/base/HeaderLogo.tsx index e90369ba..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 palette from '../../lib/styles/palette'; +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)} + ); }; @@ -48,7 +48,7 @@ const HeaderLogoBlock = styled.div` align-items: center; justify-content: center; font-weight: bold; - color: ${palette.gray8}; + color: ${themedPalette.text1}; font-size: 1.3125rem; text-decoration: none; font-family: Fira Mono, monospace; @@ -69,14 +69,16 @@ 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 { + color: inherit; margin-right: 1rem; width: 1.75rem; height: 1.75rem; diff --git a/src/components/base/HeaderUserIcon.tsx b/src/components/base/HeaderUserIcon.tsx index 61cd13ea..17b793e7 100644 --- a/src/components/base/HeaderUserIcon.tsx +++ b/src/components/base/HeaderUserIcon.tsx @@ -3,7 +3,7 @@ import styled from 'styled-components'; import { CurrentUser } from '../../lib/graphql/user'; import { MdArrowDropDown } from 'react-icons/md'; import { userThumbnail } from '../../static/images'; -import palette from '../../lib/styles/palette'; +import { themedPalette } from '../../lib/styles/themes'; import optimizeImage from '../../lib/optimizeImage'; const HeaderUserIconBlock = styled.div` @@ -20,7 +20,7 @@ const HeaderUserIconBlock = styled.div` svg { font-size: 1.5rem; margin-left: 0.25rem; - color: ${palette.gray6}; + color: ${themedPalette.text3}; transition: 0.125s all ease-in; margin-right: -0.4375rem; } @@ -31,7 +31,7 @@ const HeaderUserIconBlock = styled.div` box-shadow: 0px 0 12px rgba(0, 0, 0, 0.1); } svg { - color: ${palette.gray9}; + color: ${themedPalette.text1}; } } `; diff --git a/src/components/base/HeaderUserMenu.tsx b/src/components/base/HeaderUserMenu.tsx index 1ef27db1..b923b41f 100644 --- a/src/components/base/HeaderUserMenu.tsx +++ b/src/components/base/HeaderUserMenu.tsx @@ -3,6 +3,7 @@ import styled from 'styled-components'; import OutsideClickHandler from 'react-outside-click-handler'; import HeaderUserMenuItem from './HeaderUserMenuItem'; import media from '../../lib/styles/media'; +import { themedPalette } from '../../lib/styles/themes'; const HeaderUserMenuBlock = styled.div` position: absolute; @@ -13,7 +14,7 @@ const HeaderUserMenuBlock = styled.div` position: relative; z-index: 5; width: 12rem; - background: white; + background: ${themedPalette.bg_element1}; box-shadow: 0px 0px 8px rgba(0, 0, 0, 0.1); .mobile-only { @@ -26,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; @@ -43,7 +44,7 @@ const HeaderUserMenu: React.FC = ({
- + 내 벨로그
@@ -51,7 +52,9 @@ const HeaderUserMenu: React.FC = ({
임시 글 읽기 목록 - 설정 + + 설정 + 로그아웃
diff --git a/src/components/base/HeaderUserMenuItem.tsx b/src/components/base/HeaderUserMenuItem.tsx index ef8da664..87ffd09c 100644 --- a/src/components/base/HeaderUserMenuItem.tsx +++ b/src/components/base/HeaderUserMenuItem.tsx @@ -1,7 +1,8 @@ import * as React from 'react'; import styled from 'styled-components'; import { Link } from 'react-router-dom'; -import palette from '../../lib/styles/palette'; +import { themedPalette } from '../../lib/styles/themes'; +import VLink from '../common/VLink'; const WrapperLink = styled(Link)` display: block; @@ -9,39 +10,59 @@ const WrapperLink = styled(Link)` text-decoration: none; `; +const WrapperVLink = styled(VLink)` + display: block; + color: inherit; + text-decoration: none; +`; + const HeaderUserMenuItemBlock = styled.div` - color: ${palette.gray9}; + color: ${themedPalette.text1}; padding: 0.75rem 1rem; line-height: 1.5; font-weight: 500; cursor: pointer; &:hover { - background: ${palette.gray0}; + background: ${themedPalette.bg_element2}; + color: ${themedPalette.primary1}; } `; 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/ThemeToggleButton.tsx b/src/components/base/ThemeToggleButton.tsx new file mode 100644 index 00000000..5ebf481b --- /dev/null +++ b/src/components/base/ThemeToggleButton.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import { animated, useTransition } from 'react-spring'; +import styled from 'styled-components'; +import { themedPalette } from '../../lib/styles/themes'; +import { MoonIcon, SunIcon } from '../../static/svg'; +import { useToggleTheme } from './hooks/useToggleTheme'; + +interface Props {} + +function ThemeToggleButton(props: Props) { + const [theme, toggle] = useToggleTheme(); + + const isDark = theme === 'dark'; + const transitions = useTransition(isDark, { + initial: { + transform: 'scale(1) rotate(0deg)', + opacity: 1, + }, + from: { + transform: 'scale(0) rotate(-180deg)', + opacity: 0, + }, + enter: { + transform: 'scale(1) rotate(0deg)', + opacity: 1, + }, + leave: { + transform: 'scale(0) rotate(180deg)', + opacity: 0, + }, + + reverse: true, + }); + + return ( + + {transitions((style, item) => + item ? ( + + + + + + ) : ( + + + + + + ), + )} + + ); +} + +const IconButton = styled.button` + background: none; + border: none; + cursor: pointer; + border-radius: 50%; + width: 2.5rem; + height: 2.5rem; + margin-right: 0.25rem; + color: white; + position: relative; + + &:hover { + background: ${themedPalette.slight_layer}; + } +`; + +const Positioner = styled.div` + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +`; + +const SVGWrapper = styled.div` + color: ${themedPalette.text1}; + svg { + display: block; + } +`; + +const AnimatedSVGWrapper = animated(SVGWrapper); + +export default ThemeToggleButton; diff --git a/src/components/base/__tests__/HeaderLogo.test.tsx b/src/components/base/__tests__/HeaderLogo.test.tsx index a1c1c299..d667f6c2 100644 --- a/src/components/base/__tests__/HeaderLogo.test.tsx +++ b/src/components/base/__tests__/HeaderLogo.test.tsx @@ -8,8 +8,7 @@ describe('HeaderLogo', () => { const initialProps: HeaderLogoProps = { custom: false, userLogo: null, - velogUsername: null, - onVelogLogoClick: () => {}, + username: null, }; const utils = render( @@ -26,13 +25,13 @@ describe('HeaderLogo', () => { getByTestId('velog-logo'); }); - it('shows null when custom is true and data is not loaded', () => { + it('shows empty when custom is true and data is not loaded', () => { const utils = setup({ custom: true, userLogo: null, - velogUsername: 'velopert', + username: 'velopert', }); - expect(utils.container.innerHTML).toBe(''); + expect(utils.container.innerHTML).toBe('
'); }); it('shows custom velog title when custom is true and data is loaded', () => { const { getByText } = setup({ @@ -41,7 +40,7 @@ describe('HeaderLogo', () => { logo_image: null, title: 'VELOPERT.LOG', }, - velogUsername: 'velopert', + username: 'velopert', }); getByText('VELOPERT.LOG'); }); @@ -52,7 +51,7 @@ describe('HeaderLogo', () => { logo_image: null, title: null, }, - velogUsername: 'velopert', + username: 'velopert', }); getByText(`velopert.log`); }); diff --git a/src/components/base/__tests__/__snapshots__/HeaderUserIcon.test.tsx.snap b/src/components/base/__tests__/__snapshots__/HeaderUserIcon.test.tsx.snap index 30a92abc..6890366f 100644 --- a/src/components/base/__tests__/__snapshots__/HeaderUserIcon.test.tsx.snap +++ b/src/components/base/__tests__/__snapshots__/HeaderUserIcon.test.tsx.snap @@ -3,7 +3,7 @@ exports[`HeaderUserIcon matches snapshot 1`] = `
thumbnail 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/base/hooks/useThemeEffect.ts b/src/components/base/hooks/useThemeEffect.ts new file mode 100644 index 00000000..959bd769 --- /dev/null +++ b/src/components/base/hooks/useThemeEffect.ts @@ -0,0 +1,24 @@ +import { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { RootState } from '../../../modules'; +import darkMode from '../../../modules/darkMode'; + +export function useThemeEffect() { + const dispatch = useDispatch(); + const theme = useSelector((state: RootState) => state.darkMode.theme); + + useEffect(() => { + const systemPrefersDark = window.matchMedia( + '(prefers-color-scheme: dark)', + ).matches; + dispatch( + darkMode.actions.setSystemTheme(systemPrefersDark ? 'dark' : 'light'), + ); + }, [dispatch]); + + useEffect(() => { + if (theme !== 'default') { + document.body.dataset.theme = theme; + } + }, [theme]); +} diff --git a/src/components/base/hooks/useToggleTheme.ts b/src/components/base/hooks/useToggleTheme.ts new file mode 100644 index 00000000..4552be6c --- /dev/null +++ b/src/components/base/hooks/useToggleTheme.ts @@ -0,0 +1,28 @@ +import { useDispatch } from 'react-redux'; +import { useTheme } from '../../../lib/hooks/useTheme'; +import storage from '../../../lib/storage'; +import darkMode from '../../../modules/darkMode'; + +export function useToggleTheme() { + const dispatch = useDispatch(); + const theme = useTheme(); + + const saveToStorage = (value: 'light' | 'dark') => { + storage.setItem('theme', value); // For CSR + // save to cookie + document.cookie = `theme=${value}; path=/;`; // For SSR + }; + + const toggle = () => { + if (!theme) return; + if (theme === 'dark') { + dispatch(darkMode.actions.enableLightMode()); + saveToStorage('light'); + } else { + dispatch(darkMode.actions.enableDarkMode()); + saveToStorage('dark'); + } + }; + + return [theme, toggle] as const; +} diff --git a/src/components/common/AdFeed.tsx b/src/components/common/AdFeed.tsx new file mode 100644 index 00000000..98f3fe11 --- /dev/null +++ b/src/components/common/AdFeed.tsx @@ -0,0 +1,148 @@ +import React, { useEffect, useRef } from 'react'; +import styled from 'styled-components'; +import { mediaQuery } from '../../lib/styles/media'; +import gtag from '../../lib/gtag'; +import { themedPalette } from '../../lib/styles/themes'; +import { useTheme } from '../../lib/hooks/useTheme'; + +function AdFeed({ forPost, index }: { forPost?: boolean; index: number }) { + const ref = useRef(null); + const initializedRef = useRef(false); + // const [isMobile, setIsMobile] = useState(false); + + // useEffect(() => { + // const width = window.innerWidth; + // if (width < 768) { + // setIsMobile(true); + // } + + // setTimeout(() => { + // (window.adsbygoogle = window.adsbygoogle || []).push({}); + // }, 250); + // }, []); + useEffect(() => { + if (forPost) return; + (window.adsbygoogle = window.adsbygoogle || []).push({}); + }, [forPost]); + + useEffect(() => { + if (!forPost) return; + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting && !initializedRef.current) { + initializedRef.current = true; + (window.adsbygoogle = window.adsbygoogle || []).push({}); + } + }); + }, + { + rootMargin: '90px', + threshold: 0, + }, + ); + observer.observe(ref.current!); + return () => { + observer.disconnect(); + }; + }, [forPost]); + + const theme = useTheme(); + + if (theme === 'dark') { + return ( + { + if (forPost) { + gtag('event', 'ads_click', { event_label: index }); + } + }} + > + + + ); + } + + return ( + { + if (forPost) { + gtag('event', 'ads_click', { event_label: index }); + } + }} + > + {forPost ? ( + + ) : ( + + )} + {/* {isMobile ? ( + + ) : ( + + )} */} + + ); +} + +const Block = styled.div<{ forPost?: boolean }>` + width: 20rem; + margin: 1rem; + min-height: 23.5625rem; + height: auto; + border-radius: 4px; + box-shadow: 0 4px 16px 0 rgba(0, 0, 0, 0.04); + background: ${themedPalette.bg_element1}; + + ${mediaQuery(1056)} { + width: calc(50% - 2rem); + } + ${mediaQuery(767)} { + margin: 0; + width: 100%; + margin-top: 1rem; + margin-bottom: 1rem; + min-height: 5rem; + } +`; + +export default AdFeed; diff --git a/src/components/common/Button.tsx b/src/components/common/Button.tsx index 20ef148e..98f148c8 100644 --- a/src/components/common/Button.tsx +++ b/src/components/common/Button.tsx @@ -1,7 +1,8 @@ import * as React from 'react'; import styled, { css } from 'styled-components'; -import palette, { buttonColorMap } from '../../lib/styles/palette'; +import { buttonColorMap } from '../../lib/styles/palette'; import media from '../../lib/styles/media'; +import { themedPalette } from '../../lib/styles/themes'; type ColorType = | 'teal' @@ -26,16 +27,16 @@ const ButtonBlock = styled.button<{ outline: none; border: none; color: white; - background: ${props => buttonColorMap[props.color].background}; - color: ${props => buttonColorMap[props.color].color}; + background: ${(props) => buttonColorMap[props.color].background}; + color: ${(props) => buttonColorMap[props.color].color}; &:hover, &:focus { - background: ${props => buttonColorMap[props.color].hoverBackground}; + background: ${(props) => buttonColorMap[props.color].hoverBackground}; } border-radius: 4px; padding-top: 0; padding-bottom: 0; - ${props => + ${(props) => props.inline && css` & + & { @@ -43,7 +44,7 @@ const ButtonBlock = styled.button<{ } `} - ${props => + ${(props) => props.responsive && css` ${media.small} { @@ -54,7 +55,7 @@ const ButtonBlock = styled.button<{ } `} - ${props => + ${(props) => props.size === 'medium' && css` height: 2rem; @@ -63,27 +64,27 @@ const ButtonBlock = styled.button<{ font-size: 1rem; `} - ${props => - props.size === 'large' && - css` - height: 2.5rem; - padding-left: 1.125rem; - padding-right: 1.125rem; - & + & { - margin-left: 0.875rem; - } - font-size: 1.125rem; - `} + ${(props) => + props.size === 'large' && + css` + height: 2.5rem; + padding-left: 1.125rem; + padding-right: 1.125rem; + & + & { + margin-left: 0.875rem; + } + font-size: 1.125rem; + `} &:disabled { - cursor: not-allowed; - background: ${palette.gray3}; - color: ${palette.gray5}; - &:hover { - background: ${palette.gray3}; - color: ${palette.gray5}; - } + cursor: not-allowed; + background: ${themedPalette.bg_element4}; + color: ${themedPalette.text3}; + &:hover { + background: ${themedPalette.bg_element4}; + color: ${themedPalette.text3}; } + } `; interface ButtonProps extends Omit, 'size'> { @@ -110,7 +111,7 @@ const Button: React.FC = ({ size={size} responsive={responsive} {...htmlProps} - onClick={e => { + onClick={(e) => { if (htmlProps.onClick) { htmlProps.onClick(e); } diff --git a/src/components/common/EditRemoveGroup.tsx b/src/components/common/EditRemoveGroup.tsx index f9f56825..d71a7fc0 100644 --- a/src/components/common/EditRemoveGroup.tsx +++ b/src/components/common/EditRemoveGroup.tsx @@ -1,5 +1,5 @@ import styled from 'styled-components'; -import palette from '../../lib/styles/palette'; +import { themedPalette } from '../../lib/styles/themes'; const EditRemoveGroup = styled.div` button { @@ -9,9 +9,9 @@ const EditRemoveGroup = styled.div` background: none; font-size: inherit; cursor: pointer; - color: ${palette.gray6}; + color: ${themedPalette.text3}; &:hover { - color: ${palette.gray9}; + color: ${themedPalette.text1}; } } button + button { diff --git a/src/components/common/FlatPostCard.tsx b/src/components/common/FlatPostCard.tsx index 4a0a1e5b..77db98bb 100644 --- a/src/components/common/FlatPostCard.tsx +++ b/src/components/common/FlatPostCard.tsx @@ -1,7 +1,7 @@ import React, { useRef } from 'react'; import { Link } from 'react-router-dom'; import styled from 'styled-components'; -import palette from '../../lib/styles/palette'; +import { themedPalette } from '../../lib/styles/themes'; import { userThumbnail } from '../../static/images'; import Tag from './TagItem'; import { PartialPost } from '../../lib/graphql/post'; @@ -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; @@ -37,7 +39,7 @@ const PostCardBlock = styled.div` height: 3rem; display: block; margin-right: 1rem; - background: ${palette.gray0}; + background: ${themedPalette.bg_element2}; object-fit: cover; border-radius: 1.5rem; box-shadow: 0px 0 8px rgba(0, 0, 0, 0.1); @@ -49,13 +51,13 @@ const PostCardBlock = styled.div` } .username { font-size: 0.875rem; - color: ${palette.gray9}; + color: ${themedPalette.text1}; font-weight: bold; a { color: inherit; text-decoration: none; &:hover { - color: ${palette.gray8}; + color: ${themedPalette.text1}; } } } @@ -73,7 +75,7 @@ const PostCardBlock = styled.div` h2 { font-size: 1.5rem; margin: 0; - color: ${palette.gray9}; + color: ${themedPalette.text1}; word-break: keep-all; ${media.small} { font-size: 1rem; @@ -83,7 +85,7 @@ const PostCardBlock = styled.div` margin-bottom: 2rem; margin-top: 0.5rem; font-size: 1rem; - color: ${palette.gray7}; + color: ${themedPalette.text2}; word-break: keep-all; overflow-wrap: break-word; ${media.small} { @@ -95,7 +97,7 @@ const PostCardBlock = styled.div` display: flex; align-items: center; margin-top: 1rem; - color: ${palette.gray6}; + color: ${themedPalette.text3}; font-size: 0.875rem; ${media.small} { font-size: 0.75rem; @@ -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; @@ -115,7 +127,7 @@ const PostCardBlock = styled.div` } & + & { - border-top: 1px solid ${palette.gray2}; + border-top: 1px solid ${themedPalette.border4}; } `; @@ -126,7 +138,7 @@ interface PostCardProps { const FlatPostCard = ({ post, hideUser }: PostCardProps) => { const prefetch = usePrefetchPost(post.user.username, post.url_slug); - const prefetchTimeoutId = useRef(null); + const prefetchTimeoutId = useRef | null>(null); const onMouseEnter = () => { prefetchTimeoutId.current = setTimeout(prefetch, 2000); @@ -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} +
)} @@ -178,7 +192,7 @@ const FlatPostCard = ({ post, hideUser }: PostCardProps) => {

{post.short_description}

- {post.tags.map(tag => ( + {post.tags.map((tag) => ( ))}
@@ -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/FlatPostCardList.tsx b/src/components/common/FlatPostCardList.tsx index 4d6cc919..74c8882c 100644 --- a/src/components/common/FlatPostCardList.tsx +++ b/src/components/common/FlatPostCardList.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import styled from 'styled-components'; import PostCard, { PostCardSkeleton } from './FlatPostCard'; import { PartialPost } from '../../lib/graphql/post'; -import palette from '../../lib/styles/palette'; +import { themedPalette } from '../../lib/styles/themes'; const PostCardListBlock = styled.div``; @@ -14,7 +14,7 @@ interface PostCardListProps { const PostCardList: React.FC = ({ posts, hideUser }) => { return ( - {posts.map(post => ( + {posts.map((post) => ( ))} @@ -41,7 +41,7 @@ export function PostCardListSkeleton({ } const Separator = styled.div` - border-top: 1px solid ${palette.gray2}; + border-top: 1px solid ${themedPalette.border4}; `; export default PostCardList; 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/HorizontalTab.tsx b/src/components/common/HorizontalTab.tsx index 895a8d19..9e6ceef1 100644 --- a/src/components/common/HorizontalTab.tsx +++ b/src/components/common/HorizontalTab.tsx @@ -1,7 +1,7 @@ import React from 'react'; import styled, { css } from 'styled-components'; import { Link } from 'react-router-dom'; -import palette from '../../lib/styles/palette'; +import { themedPalette } from '../../lib/styles/themes'; import { useSpring, animated } from 'react-spring'; import media from '../../lib/styles/media'; @@ -23,7 +23,7 @@ function HorizontalTab({ theme, }: HorizontalTabProps) { const activeIndex = React.Children.toArray(children).findIndex( - tab => tab.props.name === activeTab, + (tab) => tab.props.name === activeTab, ); const ratio = 100 / children.length; @@ -40,7 +40,7 @@ function HorizontalTab({ return (
- {React.Children.map(children, tab => { + {React.Children.map(children, (tab) => { return React.cloneElement(tab, { active: tab.props.name === activeTab, width: `${tabWidth}rem`, @@ -87,7 +87,7 @@ TabItem.defaultProps = { const Block = styled.div<{ align: 'center' | 'left' }>` display: flex; - ${props => + ${(props) => props.align === 'center' && css` justify-content: center; @@ -103,11 +103,11 @@ const Indicator = styled(animated.div)<{ theme: 'teal' | 'gray' }>` display: block; position: absolute; bottom: 0px; - background: ${palette.teal5}; - ${props => + background: ${themedPalette.primary2}; + ${(props) => props.theme === 'gray' && css` - background: ${palette.gray8}; + background: ${themedPalette.border1}; `} `; @@ -120,7 +120,7 @@ const StyledLink = styled(Link)<{ ${media.small} { font-size: 1rem; } - color: ${palette.gray6}; + color: ${themedPalette.text3}; display: flex; align-items: center; justify-content: center; @@ -128,11 +128,11 @@ const StyledLink = styled(Link)<{ &.active { font-weight: bold; - color: ${palette.teal5}; - ${props => + color: ${themedPalette.primary2}; + ${(props) => props.theme === 'gray' && css` - color: ${palette.gray8}; + color: ${themedPalette.text1}; `} } `; diff --git a/src/components/common/LabelInput.tsx b/src/components/common/LabelInput.tsx index 0cac9fc4..606564a3 100644 --- a/src/components/common/LabelInput.tsx +++ b/src/components/common/LabelInput.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import styled, { css } from 'styled-components'; -import palette from '../../lib/styles/palette'; +import { themedPalette } from '../../lib/styles/themes'; import { MdLockOutline } from 'react-icons/md'; import media from '../../lib/styles/media'; const LabelInputBlock = styled.div<{ focus: boolean }>` @@ -12,17 +12,18 @@ const LabelInputBlock = styled.div<{ focus: boolean }>` label { font-weight: bold; font-size: 1.125rem; - color: ${palette.gray9}; + color: ${themedPalette.text1}; margin-bottom: 1rem; transition: all 0.125s ease-in; - ${props => + ${(props) => props.focus && css` - color: ${palette.teal7}; + color: ${themedPalette.primary1}; `} } input { + background: transparent; font-size: 1.5rem; border: none; outline: none; @@ -31,18 +32,18 @@ const LabelInputBlock = styled.div<{ focus: boolean }>` } width: 100%; - color: ${palette.gray7}; + color: ${themedPalette.text2}; transition: all 0.125s ease-in; - ${props => + ${(props) => props.focus && css` - color: ${palette.teal7}; + color: ${themedPalette.primary1}; `} &::placeholder { - color: ${palette.gray5}; + color: ${themedPalette.text3}; } &:disabled { - color: ${palette.gray6}; + color: ${themedPalette.text3}; } } .group { @@ -51,20 +52,20 @@ const LabelInputBlock = styled.div<{ focus: boolean }>` } .input-wrapper { padding-bottom: 0.5rem; - border-bottom: 1px solid ${palette.gray7}; + border-bottom: 1px solid ${themedPalette.border2}; display: flex; align-items: center; - ${props => + ${(props) => props.focus && css` - border-color: ${palette.teal7}; + border-color: ${themedPalette.primary1}; `} input { - width: 1; + width: 100%; } svg { font-size: 1.5rem; - color: ${palette.gray6}; + color: ${themedPalette.text3}; } } .width-maker { diff --git a/src/components/common/MarkdownEditor.tsx b/src/components/common/MarkdownEditor.tsx index 4f428fdf..25a10ba5 100644 --- a/src/components/common/MarkdownEditor.tsx +++ b/src/components/common/MarkdownEditor.tsx @@ -2,22 +2,25 @@ import React, { CSSProperties, useRef, useEffect, useCallback } from 'react'; import styled from 'styled-components'; import CodeMirror, { EditorFromTextArea, Editor } from 'codemirror'; import './atom-one-light.css'; +import './atom-one-dark.css'; import 'codemirror/lib/codemirror.css'; -import palette from '../../lib/styles/palette'; +import { themedPalette } from '../../lib/styles/themes'; + import 'codemirror/mode/markdown/markdown'; import 'codemirror/addon/display/placeholder'; +import { useTheme } from '../../lib/hooks/useTheme'; const MarkdownEditorBlock = styled.div` .CodeMirror { height: auto; font-size: 1.125rem; line-height: 1.5; - color: ${palette.gray8}; + color: ${themedPalette.text1}; font-family: 'Fira Mono', monospace; /* font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', */ .cm-header { line-height: 2; - color: ${palette.gray9}; + color: ${themedPalette.text1}; } .cm-header-1 { font-size: 2.5rem; @@ -35,10 +38,10 @@ const MarkdownEditorBlock = styled.div` } .cm-strong, .cm-em { - color: ${palette.gray9}; + color: ${themedPalette.text1}; } .CodeMirror-placeholder { - color: ${palette.gray5}; + color: ${themedPalette.text3}; font-style: italic; } } @@ -67,12 +70,14 @@ const MarkdownEditor = ({ [onChangeMarkdown], ); + const theme = useTheme(); + // initialize editor useEffect(() => { if (!textArea.current) return; const cm = CodeMirror.fromTextArea(textArea.current, { mode: 'markdown', - theme: 'one-light', + theme: `one-${theme}`, placeholder: '당신은 어떤 사람인가요? 당신에 대해서 알려주세요.', lineWrapping: true, }); @@ -87,7 +92,7 @@ const MarkdownEditor = ({ return () => { cm.toTextArea(); }; - }, [initialMarkdown, onChange]); + }, [initialMarkdown, onChange, theme]); return ( diff --git a/src/components/common/MarkdownRender.tsx b/src/components/common/MarkdownRender.tsx index 9616151f..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'; @@ -22,6 +21,7 @@ import remarkParse from 'remark-parse'; import stringify from 'rehype-stringify'; import { Helmet } from 'react-helmet-async'; import katexWhitelist from '../../lib/katexWhitelist'; +import { themedPalette } from '../../lib/styles/themes'; export interface MarkdownRenderProps { markdown: string; @@ -30,12 +30,13 @@ export interface MarkdownRenderProps { editing?: boolean; } +function sanitizeEventScript(htmlString: string) { + return htmlString.replace(/ on\w+="[^"]*"/g, ''); +} + const MarkdownRenderBlock = styled.div` - &.atom-one-dark { - ${prismThemes['atom-one-dark']} - } - &.atom-one-light { - ${prismThemes['atom-one-light']} + &.atom-one { + ${prismThemes['atom-one']} } &.github { ${prismThemes['github']} @@ -50,7 +51,7 @@ const MarkdownRenderBlock = styled.div` pre { font-family: 'Fira Mono', source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; - font-size: 1rem; + font-size: 0.875rem; padding: 1rem; border-radius: 4px; line-height: 1.5; @@ -94,12 +95,12 @@ const MarkdownRenderBlock = styled.div` table { min-width: 40%; max-width: 100%; - border: 1px solid ${palette.gray7}; + border: 1px solid ${themedPalette.border2}; border-collapse: collapse; font-size: 0.875rem; thead > tr > th { /* text-align: left; */ - border-bottom: 4px solid ${palette.gray7}; + border-bottom: 4px solid ${themedPalette.border2}; } th, td { @@ -109,14 +110,14 @@ const MarkdownRenderBlock = styled.div` td + td, th + th { - border-left: 1px solid ${palette.gray7}; + border-left: 1px solid ${themedPalette.border2}; } tr:nth-child(even) { - background: ${palette.gray1}; + background: ${themedPalette.bg_element2}; } tr:nth-child(odd) { - background: white; + background: ${themedPalette.bg_page1}; } } @@ -126,7 +127,8 @@ const MarkdownRenderBlock = styled.div` `; function filter(html: string) { - return sanitize(html, { + const presanitized = sanitizeEventScript(html); + return sanitize(presanitized, { allowedTags: [ 'h1', 'h2', @@ -168,11 +170,12 @@ 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'], input: ['type'], + ol: ['start'], ...katexWhitelist.attributes, }, allowedStyles: { @@ -202,7 +205,7 @@ type RenderedElement = const MarkdownRender: React.FC = ({ markdown, - codeTheme = 'atom-one-light', + codeTheme = 'atom-one', onConvertFinish, editing, }) => { @@ -210,13 +213,13 @@ const MarkdownRender: React.FC = ({ ssrEnabled ? filter( remark() + .use(breaks) .use(remarkParse) .use(slug) .use(prismPlugin) .use(embedPlugin) .use(remark2rehype, { allowDangerousHTML: true }) .use(raw) - .use(breaks) .use(math) .use(katex) .use(stringify) @@ -233,51 +236,56 @@ const MarkdownRender: React.FC = ({ const throttledUpdate = React.useMemo(() => { return throttle(delay, (markdown: string) => { remark() - .use(remarkParse) - .use(slug) - .use(prismPlugin) - .use(embedPlugin) - .use(remark2rehype, { allowDangerousHTML: true }) - .use(raw) - .use(breaks) - .use(math) - .use(katex) - .use(stringify) - .process(markdown, (err: any, file: any) => { - const lines = markdown.split(/\r\n|\r|\n/).length - const nextDelay = Math.max(Math.min(Math.floor(lines * 0.5), 1500), 22) - if (nextDelay !== delay) { - setDelay(nextDelay) - } - const html = String(file); + .use(breaks) + .use(remarkParse) + .use(slug) + .use(prismPlugin) + .use(embedPlugin) + .use(remark2rehype, { allowDangerousHTML: true }) + .use(raw) + .use(math) + .use(katex) + .use(stringify) + .process( + markdown, + (err: any, file: any) => { + const lines = markdown.split(/\r\n|\r|\n/).length; + const nextDelay = Math.max( + Math.min(Math.floor(lines * 0.5), 1500), + 22, + ); + if (nextDelay !== delay) { + setDelay(nextDelay); + } + const html = String(file); - if (onConvertFinish) { - onConvertFinish(html); - } - // load twitter script if needed - if (html.indexOf('class="twitter-tweet"') !== -1) { - // if (window && (window as any).twttr) return; - loadScript('https://platform.twitter.com/widgets.js'); - } + if (onConvertFinish) { + onConvertFinish(filter(html)); + } + // load twitter script if needed + if (html.indexOf('class="twitter-tweet"') !== -1) { + // if (window && (window as any).twttr) return; + loadScript('https://platform.twitter.com/widgets.js'); + } - if (!editing) { - setHtml(filter(html)); - return; - } + if (!editing) { + setHtml(filter(html)); + return; + } - try { - const el = parse(html); - setHasTagError(false); - setElement(el); - } catch (e) {} - }, 1) + try { + const el = parse(html); + setHasTagError(false); + setElement(el); + } catch (e) {} + }, + 1, + ); }); }, [delay, editing, onConvertFinish]); - - useEffect(() => { - throttledUpdate(markdown) + throttledUpdate(markdown); }, [markdown, throttledUpdate]); return ( diff --git a/src/components/common/OpaqueLayer.tsx b/src/components/common/OpaqueLayer.tsx index 5a2f6330..8a28a799 100644 --- a/src/components/common/OpaqueLayer.tsx +++ b/src/components/common/OpaqueLayer.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import styled, { css } from 'styled-components'; +import { themedPalette } from '../../lib/styles/themes'; import transitions from '../../lib/styles/transitions'; import zIndexes from '../../lib/styles/zIndexes'; @@ -12,10 +13,10 @@ const OpaqueLayerBlock = styled.div<{ left: 0; width: 100%; height: 100%; - background: rgba(249, 249, 249, 0.85); + background: ${themedPalette.opaque_layer}; z-index: ${zIndexes.OpaqueLayer}; - ${props => + ${(props) => props.visible ? css` animation: ${transitions.fadeIn} 0.25s forwards; @@ -33,7 +34,7 @@ const { useState, useEffect, useRef } = React; const OpaqueLayer: React.FC = ({ visible }) => { const [animate, setAnimate] = useState(false); - const timeoutId = useRef(null); + const timeoutId = useRef | null>(null); const mounted = useRef(false); const [closed, setClosed] = useState(true); diff --git a/src/components/common/PlainNavLink.tsx b/src/components/common/PlainNavLink.tsx index e6224071..5d50056a 100644 --- a/src/components/common/PlainNavLink.tsx +++ b/src/components/common/PlainNavLink.tsx @@ -1,7 +1,7 @@ import React, { HTMLProps, CSSProperties } from 'react'; import { NavLink } from 'react-router-dom'; -type PlainNavLink = HTMLProps & { +type PlainNavLinkProps = HTMLProps & { to: string; activeClassName?: string; activeStyle?: CSSProperties; @@ -11,7 +11,7 @@ type PlainNavLink = HTMLProps & { /** * Needed when StyledLink has a custom props */ -const PlainNavLink: React.FC = ({ +const PlainNavLink: React.FC = ({ to, activeClassName, activeStyle, diff --git a/src/components/common/PopupBase.tsx b/src/components/common/PopupBase.tsx index 34f92219..642857b2 100644 --- a/src/components/common/PopupBase.tsx +++ b/src/components/common/PopupBase.tsx @@ -4,6 +4,7 @@ import OpaqueLayer from './OpaqueLayer'; import zIndexes from '../../lib/styles/zIndexes'; import transitions from '../../lib/styles/transitions'; import media from '../../lib/styles/media'; +import { themedPalette } from '../../lib/styles/themes'; const PopupBaseBlock = styled.div` position: fixed; @@ -20,13 +21,13 @@ const PopupBaseBlock = styled.div` const PopupWrapper = styled.div<{ visible: boolean }>` width: 25rem; border-radius: 4px; - background: white; + background: ${themedPalette.bg_element1}; padding: 2rem 1.5rem; box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.09); ${media.small} { width: calc(100% - 2rem); } - ${props => + ${(props) => props.visible ? css` animation: ${transitions.popInFromBottom} 0.4s forwards ease-in-out; @@ -45,7 +46,7 @@ const { useState, useEffect } = React; const PopupBase: React.FC = ({ visible, children }) => { const [closed, setClosed] = useState(true); useEffect(() => { - let timeoutId: number | null = null; + let timeoutId: ReturnType | null = null; if (visible) { setClosed(false); } else { diff --git a/src/components/common/PopupOKCancel.tsx b/src/components/common/PopupOKCancel.tsx index 3b40218d..ceea9fe2 100644 --- a/src/components/common/PopupOKCancel.tsx +++ b/src/components/common/PopupOKCancel.tsx @@ -2,20 +2,20 @@ import * as React from 'react'; import styled from 'styled-components'; import PopupBase from './PopupBase'; import Button from './Button'; -import palette from '../../lib/styles/palette'; +import { themedPalette } from '../../lib/styles/themes'; const PopupOKCancelBlock = styled.div` h3 { margin: 0; font-size: 1.5rem; - color: ${palette.gray8}; + color: ${themedPalette.text1}; line-height: 1.5; font-weight: bold; } .message { line-height: 1.5; font-size: 1rem; - color: ${palette.gray7}; + color: ${themedPalette.text2}; margin-top: 1rem; margin-bottom: 1rem; white-space: pre-wrap; @@ -52,7 +52,7 @@ const PopupOKCancel: React.FC = ({
{children}
{onCancel && ( - )} diff --git a/src/components/common/PostCard.tsx b/src/components/common/PostCard.tsx index 647abb32..1befe891 100644 --- a/src/components/common/PostCard.tsx +++ b/src/components/common/PostCard.tsx @@ -2,7 +2,7 @@ import React, { useRef } from 'react'; import styled, { css } from 'styled-components'; import RatioImage from './RatioImage'; import { ellipsis } from '../../lib/styles/utils'; -import palette from '../../lib/styles/palette'; +import { themedPalette } from '../../lib/styles/themes'; import { LikeIcon } from '../../static/svg'; import { PartialPost } from '../../lib/graphql/post'; import { formatDate } from '../../lib/utils'; @@ -13,17 +13,20 @@ import Skeleton from './Skeleton'; 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; forHome?: boolean; + forPost?: boolean; }; -function PostCard({ post, forHome }: PostCardProps) { +function PostCard({ post, forHome, forPost }: PostCardProps) { const url = `/@${post.user.username}/${post.url_slug}`; const prefetch = usePrefetchPost(post.user.username, post.url_slug); - const prefetchTimeoutId = useRef(null); + const prefetchTimeoutId = useRef | null>(null); const onMouseEnter = () => { prefetchTimeoutId.current = setTimeout(prefetch, 2000); @@ -40,6 +43,10 @@ function PostCard({ post, forHome }: PostCardProps) { onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} forHome={!!forHome} + forPost={!!forPost} + onClick={() => { + gtag('event', 'recommend_click'); + }} > {post.thumbnail && ( @@ -67,7 +74,7 @@ function PostCard({ post, forHome }: PostCardProps) {