├── .nvmrc ├── .prettierignore ├── src ├── testing │ ├── styleMock.js │ ├── testStore.js │ ├── dataMock.js │ └── test-utils.jsx ├── client │ ├── modules │ │ ├── newcolor │ │ │ ├── components │ │ │ │ ├── EditCanvas │ │ │ │ │ ├── ColorRow │ │ │ │ │ │ ├── style.sass │ │ │ │ │ │ ├── index.jsx │ │ │ │ │ │ ├── ColorRow.jsx │ │ │ │ │ │ └── index.spec.jsx │ │ │ │ │ ├── index.jsx │ │ │ │ │ └── style.sass │ │ │ │ └── NewColor │ │ │ │ │ ├── style.sass │ │ │ │ │ └── index.jsx │ │ │ └── index.js │ │ ├── adminPanel │ │ │ ├── AdminPanel │ │ │ │ ├── style.sass │ │ │ │ └── index.jsx │ │ │ └── index.js │ │ ├── modal │ │ │ ├── index.jsx │ │ │ └── components │ │ │ │ ├── StatusIcon │ │ │ │ ├── CheckIcon.jsx │ │ │ │ ├── ExclamationIcon.jsx │ │ │ │ ├── InfoIcon.jsx │ │ │ │ └── index.jsx │ │ │ │ ├── Modal │ │ │ │ ├── style.sass │ │ │ │ └── index.jsx │ │ │ │ └── Portal │ │ │ │ └── index.jsx │ │ └── color │ │ │ ├── components │ │ │ ├── Box │ │ │ │ ├── components │ │ │ │ │ ├── HeartButton │ │ │ │ │ │ ├── Heart │ │ │ │ │ │ │ ├── index.spec.jsx │ │ │ │ │ │ │ ├── style.sass │ │ │ │ │ │ │ ├── hrtr.svg │ │ │ │ │ │ │ └── index.jsx │ │ │ │ │ │ ├── index.spec.jsx │ │ │ │ │ │ └── index.jsx │ │ │ │ │ ├── ColorRow │ │ │ │ │ │ ├── index.spec.jsx │ │ │ │ │ │ ├── style.sass │ │ │ │ │ │ └── index.jsx │ │ │ │ │ └── ColorCanvas │ │ │ │ │ │ ├── index.jsx │ │ │ │ │ │ ├── index.spec.jsx │ │ │ │ │ │ └── style.sass │ │ │ │ ├── index.js │ │ │ │ ├── style.sass │ │ │ │ ├── index.spec.jsx │ │ │ │ └── Box.jsx │ │ │ ├── Color │ │ │ │ ├── style.sass │ │ │ │ ├── selected.spec.jsx │ │ │ │ ├── notfound.spec.jsx │ │ │ │ ├── index.spec.jsx │ │ │ │ └── index.jsx │ │ │ └── ShareWrapper │ │ │ │ ├── style.sass │ │ │ │ ├── index.spec.jsx │ │ │ │ └── index.jsx │ │ │ └── index.js │ ├── epics │ │ ├── index.js │ │ ├── modal.js │ │ ├── admin.js │ │ ├── user.js │ │ └── color.js │ ├── misc │ │ ├── requester.js │ │ ├── likeManager.js │ │ └── util.js │ ├── bulma.modules.sass │ ├── config │ │ └── store.js │ ├── routes │ │ └── index.jsx │ └── index.jsx ├── server │ ├── middlewares │ │ ├── graphql │ │ │ ├── sample │ │ │ │ ├── session.gql │ │ │ │ ├── likeColor.gql │ │ │ │ ├── adjudicateColor.gql │ │ │ │ └── color.gql │ │ │ ├── index.js │ │ │ ├── schema.js │ │ │ └── root.js │ │ ├── csrfHandler.js │ │ ├── staticFile.js │ │ ├── errorHandler.js │ │ ├── auth.js │ │ └── render.jsx │ ├── helper.js │ ├── index.js │ ├── resource │ │ ├── mongodb │ │ │ ├── connection.js │ │ │ └── crud.js │ │ ├── redisSession.js │ │ └── oauth.js │ ├── constant.server.js │ └── app │ │ └── index.js ├── hooks │ ├── useLayoutContext.js │ └── useTranslationContext.js ├── components │ ├── SpinLoader │ │ ├── index.jsx │ │ └── style.sass │ ├── Header │ │ ├── components │ │ │ ├── Header │ │ │ │ ├── style.sass │ │ │ │ ├── index.spec.jsx │ │ │ │ └── index.jsx │ │ │ ├── ToggleButton │ │ │ │ └── index.jsx │ │ │ ├── LanguageDropdown │ │ │ │ ├── index.jsx │ │ │ │ └── index.spec.jsx │ │ │ └── TranslationIcon │ │ │ │ └── index.jsx │ │ ├── index.spec.jsx │ │ └── index.js │ ├── Layout │ │ ├── index.spec.jsx │ │ ├── style.sass │ │ └── index.jsx │ ├── OpenGraph.jsx │ └── Html.jsx ├── reducers │ ├── index.js │ ├── modal.js │ ├── admin.js │ ├── user.js │ ├── admin.spec.js │ ├── user.spec.js │ ├── color.js │ └── color.spec.js ├── constant.sass ├── constant.js ├── util │ ├── index.js │ └── index.spec.js ├── contexts │ ├── Layout │ │ └── index.jsx │ └── Language │ │ └── index.jsx └── translation │ ├── index.spec.js │ └── index.js ├── assets ├── img │ ├── logo.png │ ├── react.png │ ├── rxjs.png │ ├── graphql.png │ └── screenshot.png └── static │ ├── favicon.ico │ ├── robots.txt │ ├── sitemap.xml │ └── error.html ├── dist ├── server │ ├── favicon.ico │ ├── robots.txt │ ├── sitemap.xml │ └── error.html └── public │ ├── adminPanel.css │ ├── newColor.css │ └── adminPanel.js ├── .gitignore ├── app.yaml ├── .gcloudignore ├── .dockerignore ├── Dockerfile ├── .github ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── ci.yml ├── README.md ├── webpack ├── plugins │ └── ServerStartPlugin.js ├── base.js ├── develop.js └── production.js └── package.json /.nvmrc: -------------------------------------------------------------------------------- 1 | v22.13 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | local 3 | node_modules -------------------------------------------------------------------------------- /src/testing/styleMock.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /assets/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/im6/vp/HEAD/assets/img/logo.png -------------------------------------------------------------------------------- /assets/img/react.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/im6/vp/HEAD/assets/img/react.png -------------------------------------------------------------------------------- /assets/img/rxjs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/im6/vp/HEAD/assets/img/rxjs.png -------------------------------------------------------------------------------- /assets/img/graphql.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/im6/vp/HEAD/assets/img/graphql.png -------------------------------------------------------------------------------- /dist/server/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/im6/vp/HEAD/dist/server/favicon.ico -------------------------------------------------------------------------------- /assets/img/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/im6/vp/HEAD/assets/img/screenshot.png -------------------------------------------------------------------------------- /assets/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/im6/vp/HEAD/assets/static/favicon.ico -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | .vscode 4 | npm-debug.log 5 | package-lock.json 6 | local 7 | coverage 8 | node_modules 9 | yarn.lock 10 | -------------------------------------------------------------------------------- /dist/public/adminPanel.css: -------------------------------------------------------------------------------- 1 | .CmIth{margin:0 auto;width:950px}.CmIth>div{display:inline-block;margin-bottom:20px}@media (max-width:768px){.CmIth{width:350px}} -------------------------------------------------------------------------------- /app.yaml: -------------------------------------------------------------------------------- 1 | runtime: nodejs12 2 | instance_class: B1 3 | basic_scaling: 4 | max_instances: 3 5 | idle_timeout: 60m 6 | env_variables: 7 | HELLO: 'WORLD' 8 | -------------------------------------------------------------------------------- /assets/static/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: /portfolio 3 | Disallow: /like 4 | Disallow: /adminpanel 5 | 6 | Sitemap: https://www.colorbro.com/sitemap.xml 7 | -------------------------------------------------------------------------------- /dist/server/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: /portfolio 3 | Disallow: /like 4 | Disallow: /adminpanel 5 | 6 | Sitemap: https://www.colorbro.com/sitemap.xml 7 | -------------------------------------------------------------------------------- /.gcloudignore: -------------------------------------------------------------------------------- 1 | .nvmrc 2 | .github 3 | assets 4 | coverage 5 | local 6 | node_modules 7 | src 8 | webpack 9 | npm-debug.log 10 | package-lock.json 11 | README.md 12 | -------------------------------------------------------------------------------- /src/client/modules/newcolor/components/EditCanvas/ColorRow/style.sass: -------------------------------------------------------------------------------- 1 | 2 | .rowContainer 3 | display: flex 4 | align-items: flex-end 5 | justify-content: flex-end 6 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | .idea 4 | .vscode 5 | .nvmrc 6 | .env 7 | npm-debug.log 8 | package-lock.json 9 | local 10 | dist/public 11 | coverage 12 | assets 13 | README.md 14 | -------------------------------------------------------------------------------- /src/server/middlewares/graphql/sample/session.gql: -------------------------------------------------------------------------------- 1 | { 2 | app { 3 | isAuth 4 | oauth 5 | oauthState 6 | tokenInfo 7 | dbInfo { 8 | id 9 | name 10 | isAdmin 11 | } 12 | } 13 | } -------------------------------------------------------------------------------- /src/hooks/useLayoutContext.js: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import { LayoutContext } from '../contexts/Layout'; 3 | 4 | const useLayoutContext = () => useContext(LayoutContext); 5 | export default useLayoutContext; 6 | -------------------------------------------------------------------------------- /src/hooks/useTranslationContext.js: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import { LanguageContext } from '../contexts/Language'; 3 | 4 | const useTranslationContext = () => useContext(LanguageContext); 5 | export default useTranslationContext; 6 | -------------------------------------------------------------------------------- /src/server/middlewares/graphql/sample/likeColor.gql: -------------------------------------------------------------------------------- 1 | 2 | mutation($val: LikeColorInputType!) { 3 | likeColor(input: $val) { 4 | status 5 | } 6 | } 7 | 8 | { 9 | "val": { 10 | "id": 1, 11 | "willLike": true 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/server/middlewares/graphql/sample/adjudicateColor.gql: -------------------------------------------------------------------------------- 1 | mutation($val: LikeColorInputType!) { 2 | adjudicateColor(input: $val) { 3 | status 4 | } 5 | } 6 | 7 | { 8 | "val": { 9 | "id": 1, 10 | "willLike": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/components/SpinLoader/index.jsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react'; 2 | import './style.sass'; 3 | 4 | const SpinLoader = () => ( 5 |
6 |
7 |
8 | ); 9 | export default memo(SpinLoader); 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:12.14.1 2 | RUN mkdir -p /home/app/vp/node_modules && chown -R node:node /home/app/vp 3 | WORKDIR /home/app/vp 4 | COPY package.json ./ 5 | USER node 6 | RUN npm i --production 7 | COPY --chown=node:node ./dist ./dist 8 | EXPOSE 3000 9 | CMD [ "node", "dist/server" ] -------------------------------------------------------------------------------- /src/client/modules/adminPanel/AdminPanel/style.sass: -------------------------------------------------------------------------------- 1 | .container 2 | // border: 1px solid red 3 | width: 950px 4 | margin: 0 auto 5 | &>div 6 | display: inline-block 7 | margin-bottom: 20px 8 | 9 | @media (max-width: 768px) 10 | .container 11 | width: 350px -------------------------------------------------------------------------------- /src/server/middlewares/graphql/sample/color.gql: -------------------------------------------------------------------------------- 1 | query($cate: ColorCategory!) { 2 | color(category: $cate) { 3 | id 4 | like 5 | color 6 | userId 7 | username 8 | createdDate 9 | } 10 | } 11 | 12 | { 13 | "cate": "PUBLIC" // "ANONYMOUS" 14 | } 15 | -------------------------------------------------------------------------------- /src/client/modules/modal/index.jsx: -------------------------------------------------------------------------------- 1 | import { useSelector } from 'react-redux'; 2 | import Modal from './components/Modal'; 3 | 4 | const ModalContainer = () => { 5 | const state = useSelector(({ modal }) => modal); 6 | return ; 7 | }; 8 | 9 | export default ModalContainer; 10 | -------------------------------------------------------------------------------- /src/client/epics/index.js: -------------------------------------------------------------------------------- 1 | import { combineEpics } from 'redux-observable'; 2 | 3 | const context = require.context('./', false, /^.{2}(?!index).*\.js$/); 4 | const keys = context.keys(); 5 | const epics = keys.reduce((acc, v) => acc.concat(context(v).default), []); 6 | 7 | export default combineEpics(...epics); 8 | -------------------------------------------------------------------------------- /src/server/middlewares/graphql/index.js: -------------------------------------------------------------------------------- 1 | import { graphqlHTTP } from 'express-graphql'; 2 | import rootValue from './root'; 3 | import schema from './schema'; 4 | 5 | export default graphqlHTTP({ 6 | schema, 7 | rootValue, 8 | graphiql: process.env.NODE_ENV === 'development', 9 | pretty: process.env.NODE_ENV === 'development', 10 | }); 11 | -------------------------------------------------------------------------------- /src/testing/testStore.js: -------------------------------------------------------------------------------- 1 | import { configureStore } from '@reduxjs/toolkit'; 2 | import user from '../reducers/user'; 3 | import admin from '../reducers/admin'; 4 | import color from '../reducers/color'; 5 | 6 | const store = configureStore({ 7 | reducer: { 8 | user, 9 | admin, 10 | color, 11 | }, 12 | }); 13 | 14 | export default store; 15 | -------------------------------------------------------------------------------- /src/client/modules/color/components/Box/components/HeartButton/Heart/index.spec.jsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import Heart from '.'; 3 | 4 | describe('render properly', () => { 5 | test('render text correct', () => { 6 | const { getByText } = render(); 7 | expect(getByText('Grey Heart')).toBeTruthy(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/client/modules/color/components/Color/style.sass: -------------------------------------------------------------------------------- 1 | .list 2 | display: flex 3 | flex-wrap: wrap 4 | justify-content: center 5 | width: 92% 6 | margin: 0 auto 7 | .text 8 | h1 9 | text-align: center; 10 | 11 | @media (max-width: 768px) 12 | .list 13 | width: 99% 14 | 15 | @media (max-width: 321px) 16 | .list 17 | width: 100% 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/client/modules/color/components/Box/components/HeartButton/Heart/style.sass: -------------------------------------------------------------------------------- 1 | .red 2 | fill: #ff8c94 3 | stroke: #ff8c94 4 | stroke-linecap: round 5 | stroke-linejoin: round 6 | stroke-width: 1px 7 | .grey 8 | fill: #cfcfcf 9 | stroke: #cfcfcf 10 | stroke-linecap: round 11 | stroke-linejoin: round 12 | stroke-width: 1px 13 | 14 | .noTooltip 15 | pointer-events: none 16 | -------------------------------------------------------------------------------- /src/client/modules/newcolor/components/EditCanvas/ColorRow/index.jsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react'; 2 | import ColorRow from './ColorRow'; 3 | 4 | const isEqual = (prevProps, nextProps) => { 5 | const isSame = 6 | nextProps.colorValue == prevProps.colorValue && 7 | nextProps.isActive == prevProps.isActive; 8 | return isSame; 9 | }; 10 | 11 | export default memo(ColorRow, isEqual); 12 | -------------------------------------------------------------------------------- /src/client/misc/requester.js: -------------------------------------------------------------------------------- 1 | import { ajax } from 'rxjs/ajax'; 2 | 3 | const tokenElem = document.querySelector('#csrf'); 4 | const { token: _csrf } = tokenElem.dataset; 5 | 6 | export default (body) => 7 | ajax({ 8 | url: '/graphql', 9 | method: 'POST', 10 | headers: { 'Content-Type': 'application/json' }, 11 | body: { 12 | _csrf, 13 | ...body, 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /src/client/modules/color/components/Box/index.js: -------------------------------------------------------------------------------- 1 | import { memo } from 'react'; 2 | import Box from './Box'; 3 | 4 | const isEqual = (prevProps, nextProps) => { 5 | const { starred, id, vertical } = prevProps; 6 | return ( 7 | nextProps.starred === starred && 8 | nextProps.id === id && 9 | nextProps.vertical === vertical 10 | ); 11 | }; 12 | 13 | export default memo(Box, isEqual); 14 | -------------------------------------------------------------------------------- /src/client/modules/newcolor/components/NewColor/style.sass: -------------------------------------------------------------------------------- 1 | $mw: 375px 2 | 3 | .container 4 | margin: 60px auto 0 auto 5 | max-width: $mw 6 | width: 96% 7 | 8 | .floor0 9 | display: flex 10 | &>div:first-child 11 | width: 65% 12 | &>div:nth-child(2) 13 | width: 35% 14 | 15 | .floor1 16 | display: flex 17 | &>button 18 | flex: 1 19 | margin: 5px 20 | -------------------------------------------------------------------------------- /src/components/Header/components/Header/style.sass: -------------------------------------------------------------------------------- 1 | .selected 2 | color: #3273dc !important 3 | .translationText 4 | margin-left: 5px 5 | 6 | .rotate 7 | transform: rotate(-90deg) 8 | .iconWrapper 9 | cursor: pointer 10 | 11 | @media only screen and (min-device-width : 375px) and (max-device-width : 812px) and (orientation : portrait) 12 | .wideScreenOnly 13 | display: none !important 14 | -------------------------------------------------------------------------------- /src/reducers/index.js: -------------------------------------------------------------------------------- 1 | /* eslint func-names: 0 */ 2 | const context = require.context( 3 | './', 4 | false, 5 | /^(?!.*(\.spec|index)\.js$).*\.js$/ 6 | ); 7 | const keys = context.keys(); 8 | 9 | export default keys.reduce((res, v) => { 10 | const str0 = v.replace(/\.js$/, ''); 11 | const str1 = str0.replace(/^.\//, ''); 12 | res[str1] = context(v).default; 13 | return res; 14 | }, {}); 15 | -------------------------------------------------------------------------------- /src/client/modules/color/components/Box/components/HeartButton/Heart/hrtr.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/Layout/index.spec.jsx: -------------------------------------------------------------------------------- 1 | import { render } from 'test-utils'; 2 | import Layout from '.'; 3 | 4 | describe('render properly', () => { 5 | test('render Layout correct', () => { 6 | const text = 'hello world'; 7 | const { getByText } = render( 8 | 9 |

{text}

10 |
11 | ); 12 | expect(getByText(text)).toBeTruthy(); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/constant.sass: -------------------------------------------------------------------------------- 1 | $layout-background-color: rgba(230, 230, 230, 0.95) 2 | 3 | $box-padding: 7px 4 | $box-padding-mobile: 5px 5 | $box-width: 206px 6 | $box-width-mobile: 142px 7 | $box-margin: 8px 8 | $box-border-radius: 4px 9 | 10 | $canvas-height-style-0: 230px 11 | $canvas-height-style-1: 130px 12 | $canvas-height-style-0-mobile: 160px 13 | $canvas-height-style-1-mobile: 100px 14 | $canvas-border-radius: 3px 15 | -------------------------------------------------------------------------------- /src/components/Layout/style.sass: -------------------------------------------------------------------------------- 1 | @import "../../constant.sass" 2 | @import url('https://fonts.googleapis.com/css?family=Roboto&display=swap') 3 | 4 | body 5 | margin: 0 6 | & > div 7 | background-color: $layout-background-color 8 | 9 | a,h1,h2,h3,h4,h5,p,span,li,div,button 10 | font-family: 'Roboto', sans-serif 11 | 12 | a 13 | text-decoration: none 14 | 15 | .container 16 | padding: 65px 0 17 | min-height: 1300px 18 | -------------------------------------------------------------------------------- /src/server/helper.js: -------------------------------------------------------------------------------- 1 | import get from 'lodash.get'; 2 | 3 | export const isAuth = (req, ignoreUserId) => 4 | Boolean( 5 | get(req, 'session.app.isAuth', false) && 6 | (ignoreUserId || get(req, 'session.app.dbInfo.id', null)) 7 | ); 8 | 9 | export const getTokenInfo = (req) => get(req, 'session.app.tokenInfo', null); 10 | 11 | export const isAdmin = (req) => 12 | isAuth(req) && get(req, 'session.app.dbInfo.isAdmin', false); 13 | -------------------------------------------------------------------------------- /src/client/modules/modal/components/StatusIcon/CheckIcon.jsx: -------------------------------------------------------------------------------- 1 | const CheckIcon = () => ( 2 | 6 | ); 7 | 8 | export default CheckIcon; 9 | -------------------------------------------------------------------------------- /src/server/middlewares/csrfHandler.js: -------------------------------------------------------------------------------- 1 | import csrf from 'csurf'; 2 | import get from 'lodash.get'; 3 | import { CSRF_EXCEPTION } from '../constant.server'; 4 | 5 | const csrfOrigin = csrf(); 6 | export default (...args) => { 7 | // args will be [req, res, next] 8 | const self = this; 9 | if (get(args[0], 'body._csrf', null) === CSRF_EXCEPTION) { 10 | console.log('csrf exception'); // eslint-disable-line no-console 11 | args[2](); 12 | } else { 13 | csrfOrigin.apply(self, args); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /src/client/modules/color/components/ShareWrapper/style.sass: -------------------------------------------------------------------------------- 1 | .center 2 | display: flex 3 | justify-content: center 4 | padding: 0 8px 5 | 6 | .shareGroup 7 | margin-top: 6px 8 | &>button 9 | font-size: 0.7rem 10 | display: block 11 | text-overflow: ellipsis 12 | overflow: hidden 13 | width: 74.5px 14 | 15 | 16 | 17 | @media only screen and (min-device-width : 375px) and (max-device-width : 812px) and (orientation : portrait) 18 | .shareGroup >button 19 | font-size: 8px 20 | width: 52px -------------------------------------------------------------------------------- /src/testing/dataMock.js: -------------------------------------------------------------------------------- 1 | export const selectedColor = '#4c286f'; 2 | export const boxInfo = { 3 | id: '1', 4 | star: 4, 5 | color: `e5d12f#e5632f#d71a64${selectedColor}`, 6 | userId: null, 7 | username: 'tom', 8 | createdDate: '1522956515000', 9 | }; 10 | 11 | export const colorDefSample = { 12 | 1: boxInfo, 13 | 2: { 14 | id: '2', 15 | star: 4, 16 | color: '2fe5d1#32e56f#d7641a#46fc28', 17 | userId: null, 18 | username: 'harry', 19 | createdDate: '1522956515000', 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /src/client/modules/color/components/Box/components/ColorRow/index.spec.jsx: -------------------------------------------------------------------------------- 1 | import { render, fireEvent } from '@testing-library/react'; 2 | import ColorRow from '.'; 3 | 4 | describe('render properly', () => { 5 | const clickCb = jest.fn(); 6 | test('render color correct', () => { 7 | const color = '#c0ccc9'; 8 | const { getByText } = render( 9 | 10 | ); 11 | fireEvent.click(getByText(color)); 12 | expect(getByText(color)).toBeTruthy(); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/client/modules/modal/components/StatusIcon/ExclamationIcon.jsx: -------------------------------------------------------------------------------- 1 | const ExclamationIcon = () => ( 2 | 6 | ); 7 | 8 | export default ExclamationIcon; 9 | -------------------------------------------------------------------------------- /src/client/modules/newcolor/index.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { createAction } from '@reduxjs/toolkit'; 3 | import NewColor from './components/NewColor'; 4 | 5 | const mapDispatchToProps = (dispatch) => ({ 6 | onAdd(color) { 7 | const ac = createAction('color/addNew'); 8 | dispatch(ac({ color })); 9 | }, 10 | onColorInvalid() { 11 | const ac = createAction('modal'); 12 | dispatch(ac(['danger', 'Invalid color value.'])); 13 | }, 14 | }); 15 | 16 | export default connect(null, mapDispatchToProps)(NewColor); 17 | -------------------------------------------------------------------------------- /src/server/index.js: -------------------------------------------------------------------------------- 1 | /* eslint no-console: 0 */ 2 | import { exec } from 'child_process'; 3 | import app from './app'; 4 | import { PORT } from './constant.server'; 5 | 6 | const server = app.listen(PORT, () => 7 | console.log( 8 | `app (mode: ${process.env.NODE_ENV}) is running on: http://localhost:${PORT}` 9 | ) 10 | ); 11 | server.timeout = 1000 * 5; 12 | 13 | exec('curl ifconfig.me', (err, stdout, stderr) => { 14 | if (err) { 15 | console.error(stderr); 16 | return; 17 | } 18 | console.log(`public IP addr: ${stdout}`); 19 | }); 20 | -------------------------------------------------------------------------------- /src/client/modules/modal/components/StatusIcon/InfoIcon.jsx: -------------------------------------------------------------------------------- 1 | const InfoIcon = () => ( 2 | 6 | ); 7 | 8 | export default InfoIcon; 9 | -------------------------------------------------------------------------------- /src/constant.js: -------------------------------------------------------------------------------- 1 | const { version } = require('../package.json'); 2 | 3 | export const tempDomId = 'temp'; 4 | export const defaultLanguageKey = 'en'; 5 | 6 | export const canvasDefaultVertical = true; 7 | export const imgCdnUrl = '//dkny.oss-cn-hangzhou.aliyuncs.com/2'; 8 | export const codeCdnUrl = `https://cdn.jsdelivr.net/gh/im6/vp@v${version}/dist/public`; 9 | 10 | // cookie key 11 | export const cookieExpireTime = 180; 12 | export const canvasOrientationKey = 'canvas'; 13 | export const langSelectionKey = 'lang'; 14 | export const reduxName = '_REDUXSTATE_'; 15 | -------------------------------------------------------------------------------- /src/server/middlewares/staticFile.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { SERVER_STATIC_PATH, SERVER_META_FILES } from '../constant.server'; 3 | 4 | const fileSet = SERVER_META_FILES.reduce((acc, cur) => { 5 | acc[cur] = true; 6 | return acc; 7 | }, {}); 8 | 9 | export default (req, res, next) => { 10 | const { url } = req; 11 | if (Object.prototype.hasOwnProperty.call(fileSet, url)) { 12 | const filePath = path.resolve(process.cwd(), `${SERVER_STATIC_PATH}${url}`); 13 | res.sendFile(filePath); 14 | } else { 15 | next(404); 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /src/components/Header/index.spec.jsx: -------------------------------------------------------------------------------- 1 | import { render, fireEvent } from 'test-utils'; 2 | import Header, { mapDispatchToProps } from '.'; 3 | 4 | describe('render properly', () => { 5 | test('render LangDropdown correct', () => { 6 | const { getByTitle, getByText } = render(
); 7 | fireEvent.click(getByTitle('click to rotate')); 8 | fireEvent.click(getByText('English')); 9 | const dispatch = jest.fn(); 10 | const mapper = mapDispatchToProps(dispatch); 11 | mapper.onLogout(); 12 | expect(dispatch).toBeCalledTimes(1); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/components/Header/components/ToggleButton/index.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | 3 | const ToggleButton = ({ onClick }) => ( 4 | 15 | ); 16 | 17 | ToggleButton.propTypes = { 18 | onClick: PropTypes.func.isRequired, 19 | }; 20 | 21 | export default ToggleButton; 22 | -------------------------------------------------------------------------------- /src/client/modules/color/components/Box/components/HeartButton/index.spec.jsx: -------------------------------------------------------------------------------- 1 | import { render, fireEvent } from '@testing-library/react'; 2 | import HeartButton from '.'; 3 | 4 | describe('render properly', () => { 5 | test('render color correct', () => { 6 | const clickFn = jest.fn(); 7 | const { container, rerender } = render( 8 | 9 | ); 10 | rerender(); 11 | fireEvent.click(container.querySelector('button')); 12 | expect(clickFn).toBeCalled(); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/components/Header/components/LanguageDropdown/index.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | 3 | const LanguageDropdown = ({ languages, onChange }) => ( 4 |
5 | {languages.map((v) => ( 6 | { 10 | onChange(v.code); 11 | }} 12 | > 13 | {v.name} 14 | 15 | ))} 16 |
17 | ); 18 | 19 | LanguageDropdown.propTypes = { 20 | onChange: PropTypes.func.isRequired, 21 | languages: PropTypes.array.isRequired, 22 | }; 23 | 24 | export default LanguageDropdown; 25 | -------------------------------------------------------------------------------- /src/client/bulma.modules.sass: -------------------------------------------------------------------------------- 1 | 2 | $control-height: auto; // disable the default larger button height 3 | $control-padding-vertical: calc(0.375em - 1px) 4 | $control-padding-horizontal: calc(0.625em - 1px) 5 | $button-padding-vertical: calc(0.375em - 1px) 6 | $button-padding-horizontal: 0.75em 7 | 8 | @import "node_modules/bulma/sass/utilities/_all.sass" 9 | @import "node_modules/bulma/sass/elements/button.sass" 10 | @import "node_modules/bulma/sass/components/navbar.sass" 11 | @import "node_modules/bulma/sass/elements/notification.sass" 12 | 13 | .navbar-link:not(.is-arrowless)::after 14 | margin-top: -8px 15 | .navbar-item 16 | font-size: 14px 17 | -------------------------------------------------------------------------------- /src/util/index.js: -------------------------------------------------------------------------------- 1 | export const isColorHex = (str) => /^(#[a-f0-9]{6}){4}$/.test(str); 2 | 3 | export const isFourDiff = (str) => { 4 | const checkObj = { 5 | cnt: 0, 6 | }; 7 | const strArray = [ 8 | str.substring(0, 7), 9 | str.substring(7, 14), 10 | str.substring(14, 21), 11 | str.substring(21), 12 | ]; 13 | strArray.forEach((v) => { 14 | if (!Object.prototype.hasOwnProperty.call(checkObj, v)) { 15 | checkObj[v] = true; 16 | checkObj.cnt += 1; 17 | } 18 | }); 19 | 20 | return checkObj.cnt === strArray.length; 21 | }; 22 | 23 | export const isValidColorStr = (str) => isColorHex(str) && isFourDiff(str); 24 | -------------------------------------------------------------------------------- /src/client/modules/adminPanel/index.js: -------------------------------------------------------------------------------- 1 | import { createAction } from '@reduxjs/toolkit'; 2 | import { connect } from 'react-redux'; 3 | import AdminPanel from './AdminPanel'; 4 | 5 | const mapStateToProps = ({ admin }) => admin; 6 | 7 | const mapDispatchToProps = (dispatch) => ({ 8 | onInitList() { 9 | const ac = createAction('admin/getList'); 10 | dispatch(ac()); 11 | }, 12 | onAdjudicate(id, willLike) { 13 | const ac = createAction('admin/decideColor'); 14 | dispatch( 15 | ac({ 16 | id, 17 | willLike, 18 | }) 19 | ); 20 | }, 21 | }); 22 | 23 | export default connect(mapStateToProps, mapDispatchToProps)(AdminPanel); 24 | -------------------------------------------------------------------------------- /src/client/modules/color/components/Box/components/HeartButton/index.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import Heart from './Heart'; 3 | 4 | const HeartButton = ({ starred, onClick, starNum }) => ( 5 | 15 | ); 16 | 17 | HeartButton.propTypes = { 18 | starNum: PropTypes.number.isRequired, 19 | starred: PropTypes.bool, 20 | onClick: PropTypes.func.isRequired, 21 | }; 22 | 23 | export default HeartButton; 24 | -------------------------------------------------------------------------------- /src/client/modules/color/components/Box/components/ColorRow/style.sass: -------------------------------------------------------------------------------- 1 | 2 | .rowContainer 3 | position: relative 4 | 5 | .text 6 | position: absolute 7 | background: rgba(110, 110, 110, 0.4); 8 | bottom: 0 9 | left: 0 10 | transition: opacity 0.25s ease-in-out 11 | color: white 12 | opacity: 0 13 | padding: 0 10px 14 | margin: 0 15 | text-transform: uppercase 16 | 17 | &:hover .text 18 | opacity: 1 19 | 20 | @media only screen and (min-device-width : 375px) and (max-device-width : 812px) and (orientation : portrait) 21 | .rowContainer .text 22 | padding: 0 // fix vertical hover style in safari mobile 23 | display: none 24 | -------------------------------------------------------------------------------- /src/components/Header/components/TranslationIcon/index.jsx: -------------------------------------------------------------------------------- 1 | const TranslationIcon = () => ( 2 | 8 | translation 9 | 10 | 11 | 12 | ); 13 | 14 | export default TranslationIcon; 15 | -------------------------------------------------------------------------------- /src/client/modules/color/components/Box/components/HeartButton/Heart/index.jsx: -------------------------------------------------------------------------------- 1 | import { memo } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import * as style from './style.sass'; 4 | 5 | const Heart = ({ red }) => ( 6 | 13 | {red ? 'Red Heart' : 'Grey Heart'} 14 | 18 | 19 | ); 20 | 21 | Heart.propTypes = { 22 | red: PropTypes.bool, 23 | }; 24 | 25 | export default memo(Heart); 26 | -------------------------------------------------------------------------------- /src/components/Header/components/LanguageDropdown/index.spec.jsx: -------------------------------------------------------------------------------- 1 | import { render, fireEvent } from 'test-utils'; 2 | import LangDropdown from './index'; 3 | 4 | describe('render properly', () => { 5 | const languages = [ 6 | { 7 | code: 'eng', 8 | name: 'English', 9 | }, 10 | { 11 | code: 'chn', 12 | name: 'Chinese', 13 | }, 14 | ]; 15 | test('render LangDropdown correct', () => { 16 | const onChange = jest.fn(); 17 | const selectedLang = languages[0]; 18 | const { getByText } = render( 19 | 20 | ); 21 | fireEvent.click(getByText(selectedLang.name)); 22 | expect(onChange).toBeCalledWith(selectedLang.code); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/components/Header/index.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux'; 2 | import { createAction } from '@reduxjs/toolkit'; 3 | import Header from './components/Header'; 4 | import { languages } from '../../translation'; 5 | 6 | const mapStateToProps = ({ user, color }) => { 7 | const { detail, weiboUrl, githubUrl, facebookUrl } = user; 8 | const likeNum = color.liked.length; 9 | 10 | return { 11 | detail, 12 | likeNum, 13 | languages, 14 | weiboUrl, 15 | githubUrl, 16 | facebookUrl, 17 | }; 18 | }; 19 | 20 | export const mapDispatchToProps = (dispatch) => ({ 21 | onLogout() { 22 | const ac = createAction('user/logoff'); 23 | dispatch(ac()); 24 | }, 25 | }); 26 | 27 | export default connect(mapStateToProps, mapDispatchToProps)(Header); 28 | -------------------------------------------------------------------------------- /src/client/modules/color/components/Box/components/ColorRow/index.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import * as style from './style.sass'; 3 | 4 | const ColorRow = ({ color, onClickText }) => { 5 | const onClickTextLocal = (evt) => { 6 | onClickText(evt.target.innerText); 7 | evt.stopPropagation(); 8 | }; 9 | return ( 10 |
  • 11 | 18 |
  • 19 | ); 20 | }; 21 | 22 | ColorRow.propTypes = { 23 | color: PropTypes.string.isRequired, 24 | onClickText: PropTypes.func.isRequired, 25 | }; 26 | 27 | export default ColorRow; 28 | -------------------------------------------------------------------------------- /src/reducers/modal.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | import { createAction, createReducer } from '@reduxjs/toolkit'; 3 | 4 | const initialState = { 5 | type: null, 6 | message: null, 7 | visible: false, 8 | }; 9 | 10 | const modal = createReducer(initialState, (builder) => { 11 | builder 12 | .addCase(createAction('modal/reset'), () => initialState) 13 | .addCase(createAction('modal/set'), (_, action) => ({ 14 | type: action.payload[0], 15 | message: action.payload[1], 16 | visible: false, 17 | })) 18 | .addCase(createAction('modal/show'), (state) => { 19 | state.visible = true; 20 | }) 21 | .addCase(createAction('modal/hide'), (state) => { 22 | state.visible = false; 23 | }); 24 | }); 25 | 26 | export default modal; 27 | -------------------------------------------------------------------------------- /src/client/modules/color/components/Box/components/ColorCanvas/index.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import * as style from './style.sass'; 3 | import ColorRow from '../ColorRow'; 4 | 5 | const ColorCanvas = ({ colorValue, vertical, onClickCanvas, onClickText }) => ( 6 |
      10 | {colorValue.split('#').map((v) => ( 11 | 12 | ))} 13 |
    14 | ); 15 | 16 | ColorCanvas.propTypes = { 17 | colorValue: PropTypes.string.isRequired, 18 | onClickText: PropTypes.func.isRequired, 19 | onClickCanvas: PropTypes.func.isRequired, 20 | vertical: PropTypes.bool, 21 | }; 22 | 23 | export default ColorCanvas; 24 | -------------------------------------------------------------------------------- /src/client/modules/modal/components/Modal/style.sass: -------------------------------------------------------------------------------- 1 | // https://css-tricks.com/pop-from-top-notification/ 2 | 3 | $margin: 8px 4 | $margin-mobile: 3px 5 | 6 | .visible 7 | transform: translateY(0px) 8 | 9 | .hidden 10 | transform: translateY(-75px) 11 | 12 | .box 13 | position: fixed 14 | top: $margin 15 | right: $margin 16 | z-index: 30 17 | display: flex 18 | transition: transform 0.35s; 19 | transition-timing-function: ease; 20 | 21 | .text 22 | line-height: 25px 23 | text-align: center 24 | margin-left: 10px 25 | font-size: 1.1rem 26 | 27 | @media only screen and (min-device-width : 375px) and (max-device-width : 812px) and (orientation : portrait) 28 | .box 29 | top: $margin-mobile 30 | right: $margin-mobile 31 | .text 32 | font-size: 0.8rem 33 | 34 | -------------------------------------------------------------------------------- /src/server/resource/mongodb/connection.js: -------------------------------------------------------------------------------- 1 | /* eslint no-console: 0 */ 2 | import { MongoClient, ServerApiVersion } from 'mongodb'; 3 | 4 | const { MONGO_CONN, MONGO_CRED_PATH: credentials } = process.env; 5 | 6 | if (!MONGO_CONN || !credentials) { 7 | throw new Error('MongoDB connection info is missing.'); 8 | } 9 | 10 | const client = new MongoClient(MONGO_CONN, { 11 | tlsCertificateKeyFile: credentials, 12 | serverApi: ServerApiVersion.v1, 13 | }); 14 | 15 | export const connect = async () => { 16 | try { 17 | await client.connect(); 18 | console.log('Connected to MongoDB successfully.'); 19 | } catch (err) { 20 | console.error('MongoDB connection error!', err); 21 | client.close(); 22 | process.exit(1); 23 | } 24 | }; 25 | 26 | export const clientConn = client; 27 | -------------------------------------------------------------------------------- /src/contexts/Layout/index.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import { createContext, useState, useEffect } from 'react'; 3 | import Layout from 'components/Layout'; 4 | 5 | export const LayoutContext = createContext(); 6 | 7 | export const LayoutProvider = ({ children, initVertical, onChange }) => { 8 | const [isVertical, setVertical] = useState(initVertical); 9 | useEffect(() => { 10 | onChange(isVertical); // trigger client-only side effect 11 | }, [isVertical]); 12 | 13 | return ( 14 | 15 | {children} 16 | 17 | ); 18 | }; 19 | 20 | LayoutProvider.propTypes = { 21 | children: PropTypes.node, 22 | initVertical: PropTypes.bool, 23 | onChange: PropTypes.func, 24 | }; 25 | -------------------------------------------------------------------------------- /src/contexts/Language/index.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import { createContext, useState, useEffect } from 'react'; 3 | import { translation as allTranslation } from '../../translation'; 4 | 5 | export const LanguageContext = createContext(); 6 | 7 | export const LanguageProvider = ({ children, initLang, onChange }) => { 8 | const [lang, setLang] = useState(initLang); 9 | useEffect(() => { 10 | onChange(lang); // trigger client-only side effect 11 | }, [lang]); 12 | 13 | return ( 14 | 15 | {children} 16 | 17 | ); 18 | }; 19 | 20 | LanguageProvider.propTypes = { 21 | children: PropTypes.element.isRequired, 22 | initLang: PropTypes.string, 23 | onChange: PropTypes.func, 24 | }; 25 | -------------------------------------------------------------------------------- /src/client/modules/modal/components/Portal/index.jsx: -------------------------------------------------------------------------------- 1 | import { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { createPortal } from 'react-dom'; 4 | import { tempDomId } from '../../../../../constant'; 5 | 6 | class Portal extends Component { 7 | constructor(props) { 8 | super(props); 9 | this.el = document.createElement('div'); 10 | this.modalRootRef = document.getElementById(tempDomId); 11 | } 12 | 13 | componentDidMount() { 14 | this.modalRootRef.appendChild(this.el); 15 | } 16 | 17 | componentWillUnmount() { 18 | this.modalRootRef.removeChild(this.el); 19 | } 20 | 21 | render() { 22 | return createPortal(this.props.children, this.el); 23 | } 24 | } 25 | 26 | Portal.propTypes = { 27 | children: PropTypes.element.isRequired, 28 | }; 29 | 30 | export default Portal; 31 | -------------------------------------------------------------------------------- /src/client/config/store.js: -------------------------------------------------------------------------------- 1 | /* eslint global-require:0, no-underscore-dangle: 0 */ 2 | import { configureStore } from '@reduxjs/toolkit'; 3 | import { createEpicMiddleware } from 'redux-observable'; 4 | import rootEpic from '../epics'; 5 | import reducerSlices from '../../reducers'; 6 | import { reduxName } from '../../constant'; 7 | 8 | const epicMiddleware = createEpicMiddleware(); 9 | const middlewares = [epicMiddleware]; 10 | 11 | if (process.env.NODE_ENV === 'development') { 12 | const logger = require('redux-logger').default; 13 | middlewares.push(logger); 14 | } 15 | 16 | const store = configureStore({ 17 | reducer: reducerSlices, 18 | middleware: () => middlewares, 19 | devTools: process.env.NODE_ENV === 'development', 20 | preloadedState: window[reduxName], 21 | }); 22 | 23 | epicMiddleware.run(rootEpic); 24 | 25 | export default store; 26 | -------------------------------------------------------------------------------- /assets/static/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | https://www.colorbro.com 5 | 2019-05-24 6 | monthly 7 | 0.8 8 | 9 | 10 | https://www.colorbro.com/latest 11 | 2019-05-24 12 | monthly 13 | 0.8 14 | 15 | 16 | https://www.colorbro.com/popular 17 | 2019-05-24 18 | monthly 19 | 0.7 20 | 21 | 22 | https://www.colorbro.com/new 23 | 2019-05-24 24 | monthly 25 | 0.7 26 | 27 | 28 | -------------------------------------------------------------------------------- /dist/server/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | https://www.colorbro.com 5 | 2019-05-24 6 | monthly 7 | 0.8 8 | 9 | 10 | https://www.colorbro.com/latest 11 | 2019-05-24 12 | monthly 13 | 0.8 14 | 15 | 16 | https://www.colorbro.com/popular 17 | 2019-05-24 18 | monthly 19 | 0.7 20 | 21 | 22 | https://www.colorbro.com/new 23 | 2019-05-24 24 | monthly 25 | 0.7 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/reducers/admin.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | import { createAction, createReducer } from '@reduxjs/toolkit'; 3 | 4 | const initialState = { 5 | list: null, 6 | loading: false, 7 | }; 8 | 9 | const admin = createReducer(initialState, (builder) => { 10 | builder 11 | .addCase(createAction('admin/getList'), (state) => { 12 | state.loading = true; 13 | }) 14 | .addCase(createAction('admin/getList/success'), (state, action) => { 15 | state.loading = false; 16 | state.list = action.payload; 17 | }) 18 | .addCase(createAction('admin/getList/fail'), (state) => { 19 | state.loading = false; 20 | state.list = []; 21 | }) 22 | .addCase(createAction('admin/decideColor'), (state, action) => { 23 | state.list = state.list.filter((v) => v.id !== action.payload.id); 24 | }); 25 | }); 26 | 27 | export default admin; 28 | -------------------------------------------------------------------------------- /src/client/modules/newcolor/components/EditCanvas/index.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import * as style from './style.sass'; 3 | import ColorRow from './ColorRow'; 4 | 5 | const EditCanvas = ({ colorValue, activeIndex, onClickRow }) => { 6 | const onRowClickLocal = (v) => () => onClickRow(v); 7 | return ( 8 |
    9 |
    10 | {colorValue.map((v, k) => ( 11 | 17 | ))} 18 |
    19 |
    20 | ); 21 | }; 22 | 23 | EditCanvas.propTypes = { 24 | colorValue: PropTypes.array.isRequired, 25 | onClickRow: PropTypes.func.isRequired, 26 | activeIndex: PropTypes.number, 27 | }; 28 | 29 | export default EditCanvas; 30 | -------------------------------------------------------------------------------- /src/client/modules/modal/components/Modal/index.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import * as style from './style.sass'; 3 | import Portal from '../Portal'; 4 | import StatusIcon from '../StatusIcon'; 5 | 6 | const Modal = ({ visible, type, message }) => { 7 | if (!type) return null; 8 | const statusStyle = visible ? style.visible : style.hidden; 9 | return ( 10 | 11 |
    12 | 13 |
    {message}
    14 |
    15 |
    16 | ); 17 | }; 18 | Modal.propTypes = { 19 | visible: PropTypes.bool.isRequired, 20 | type: PropTypes.oneOf([ 21 | 'link', 22 | 'info', 23 | 'danger', 24 | 'warning', 25 | 'success', 26 | 'primary', 27 | ]), 28 | message: PropTypes.string, 29 | }; 30 | 31 | export default Modal; 32 | -------------------------------------------------------------------------------- /src/client/modules/newcolor/components/EditCanvas/ColorRow/ColorRow.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import * as style from './style.sass'; 3 | 4 | const ColorRow = ({ onRowClick, colorValue, isActive }) => { 5 | const rowStyle = 6 | colorValue && colorValue != '#' 7 | ? { 8 | backgroundColor: colorValue, 9 | } 10 | : { 11 | border: `1px solid ${isActive ? '#1a4cb6' : '#cccccc'}`, 12 | backgroundImage: 13 | "url('data:image/png;base64,R0lGODdhCgAKAPAAAOXl5f///ywAAAAACgAKAEACEIQdqXt9GxyETrI279OIgwIAOw==')", 14 | }; 15 | 16 | return ( 17 |
    18 | ); 19 | }; 20 | 21 | ColorRow.propTypes = { 22 | colorValue: PropTypes.string, 23 | isActive: PropTypes.bool, 24 | onRowClick: PropTypes.func.isRequired, 25 | }; 26 | 27 | export default ColorRow; 28 | -------------------------------------------------------------------------------- /src/server/middlewares/errorHandler.js: -------------------------------------------------------------------------------- 1 | /* eslint no-console: 0, no-unused-vars: 0 */ 2 | import path from 'path'; 3 | import { SERVER_STATIC_PATH } from '../constant.server'; 4 | 5 | const errorPage = path.resolve( 6 | process.cwd(), 7 | `${SERVER_STATIC_PATH}/error.html` 8 | ); 9 | 10 | export const onGcpAppEngSig = (req, res) => { 11 | const { action } = req.params; 12 | res.json({ status: `${action} - ok` }); 13 | }; 14 | 15 | export const onNotFound = (req, res) => { 16 | console.error(`Error: 404, ${req.url} not found`); 17 | res.status(400).sendFile(errorPage); 18 | }; 19 | 20 | export const onError = (err, req, res, next) => { 21 | console.error(`Error: ${err.toString()}, ${req.url}`); 22 | if (req.method === 'POST') { 23 | res.status(400).json({ 24 | errors: true, // consistent with graphql error object 25 | }); 26 | } else { 27 | res.status(400).sendFile(errorPage); 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /src/client/modules/color/components/ShareWrapper/index.spec.jsx: -------------------------------------------------------------------------------- 1 | import { render, fireEvent } from 'test-utils'; 2 | import ShareWrapper from '.'; 3 | import { boxInfo } from '../../../../../testing/dataMock'; 4 | 5 | describe('render properly', () => { 6 | const shareCb = jest.fn(); 7 | const downloadCb = jest.fn(); 8 | 9 | test('render properly with click', () => { 10 | const { getByText } = render( 11 | 17 |
    18 | 19 | ); 20 | 21 | fireEvent.click(getByText('Download')); 22 | fireEvent.click(getByText('E-Mail')); 23 | fireEvent.click(getByText('FaceBook')); 24 | fireEvent.click(getByText('Twitter')); 25 | 26 | expect(shareCb).toBeCalledTimes(3); 27 | expect(downloadCb).toBeCalledTimes(1); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /src/translation/index.spec.js: -------------------------------------------------------------------------------- 1 | import { translation, languages } from '.'; 2 | 3 | describe('language list', () => { 4 | test('total language provided', () => { 5 | expect(languages).toHaveLength(5); 6 | }); 7 | }); 8 | 9 | describe('translation correctness', () => { 10 | test('key value align', () => { 11 | const engLanguage = translation[languages[0].code]; 12 | const engKeys = Object.keys(engLanguage); 13 | let allNameCovered = true; 14 | let noExtraNames = true; 15 | 16 | languages.forEach((lang) => { 17 | const transSet = translation[lang.code]; 18 | if (Object.keys(transSet).length !== engKeys.length) { 19 | noExtraNames = false; 20 | } 21 | engKeys.forEach((name) => { 22 | const res = transSet[name]; 23 | if (!res) { 24 | allNameCovered = false; 25 | } 26 | }); 27 | expect(allNameCovered).toBeTruthy(); 28 | expect(noExtraNames).toBeTruthy(); 29 | }); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /src/client/modules/newcolor/components/EditCanvas/ColorRow/index.spec.jsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import ColorRow from '.'; 3 | 4 | describe('render properly', () => { 5 | const cb = jest.fn(); 6 | test('render color correct', () => { 7 | const { rerender, container } = render( 8 | 9 | ); 10 | rerender(); 11 | expect( 12 | container.querySelector('div > div').style.backgroundColor 13 | ).toBeFalsy(); 14 | }); 15 | 16 | test('shouldComponentUpdate', () => { 17 | const { rerender, container } = render( 18 | 19 | ); 20 | rerender(); 21 | rerender(); 22 | expect(container.querySelector('div > div').style.backgroundColor).toBe( 23 | 'rgb(0, 0, 1)' 24 | ); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/client/modules/color/components/Box/components/ColorCanvas/index.spec.jsx: -------------------------------------------------------------------------------- 1 | import { render, fireEvent } from '@testing-library/react'; 2 | import ColorCanvas from '.'; 3 | 4 | describe('render properly', () => { 5 | const clickCb = jest.fn(); 6 | test('render color vertically', () => { 7 | const { container } = render( 8 | 14 | ); 15 | expect(container.querySelectorAll('li')).toHaveLength(4); 16 | }); 17 | 18 | test('handle click', () => { 19 | const clickHandle = jest.fn(); 20 | const { container } = render( 21 | 26 | ); 27 | fireEvent.click(container.querySelector('li')); 28 | expect(clickHandle).toBeCalled(); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/client/modules/color/components/Box/style.sass: -------------------------------------------------------------------------------- 1 | @import "../../../../../constant.sass" 2 | 3 | .box 4 | width: $box-width 5 | margin: $box-margin 6 | box-sizing: content-box 7 | padding: $box-padding 8 | border-radius: $box-border-radius 9 | background-color: white 10 | display: inline-block 11 | 12 | box-shadow: 0 2px 3px 0.6px #d9d9d9 13 | transition: box-shadow 0.3s ease-in-out 14 | 15 | &:hover 16 | transition: box-shadow 0.8s 17 | box-shadow: 0 6px 9px 2px #cccccc 18 | &>button 19 | margin-top: $box-padding 20 | font-size: 1.3rem 21 | &>img 22 | width: 15px 23 | margin-right: 5px 24 | &>p 25 | display: inline-block 26 | margin: 12px 0 0 12px 27 | 28 | @media only screen and (min-device-width : 375px) and (max-device-width : 812px) and (orientation : portrait) 29 | .box 30 | width: $box-width-mobile 31 | padding: $box-padding-mobile 32 | &>button 33 | margin-top: $box-padding-mobile 34 | &>p 35 | display: none 36 | -------------------------------------------------------------------------------- /src/client/epics/modal.js: -------------------------------------------------------------------------------- 1 | import { of, switchMap, delay, concat } from 'rxjs'; 2 | import { ofType } from 'redux-observable'; 3 | 4 | const actionDelay = 50; // give some space between each redux action 5 | const modalDisplayTimeout = 2500; 6 | const transitionTime = 350; // same as Modal style transition time value 7 | 8 | const modalMoveEpic$ = (action$) => 9 | action$.pipe( 10 | ofType('modal'), 11 | switchMap((v) => 12 | concat( 13 | of({ 14 | type: 'modal/reset', 15 | }), 16 | of({ 17 | type: 'modal/set', 18 | payload: v.payload, 19 | }).pipe(delay(actionDelay)), 20 | of({ 21 | type: 'modal/show', 22 | }).pipe(delay(actionDelay)), 23 | of({ 24 | type: 'modal/hide', 25 | }).pipe(delay(modalDisplayTimeout)), 26 | of({ 27 | type: 'modal/reset', 28 | }).pipe(delay(transitionTime + 1)) 29 | ) 30 | ) 31 | ); 32 | 33 | export default [modalMoveEpic$]; 34 | -------------------------------------------------------------------------------- /src/client/modules/modal/components/StatusIcon/index.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | 3 | import InfoIcon from './InfoIcon'; 4 | import CheckIcon from './CheckIcon'; 5 | import ExclamationIcon from './ExclamationIcon'; 6 | 7 | const StatusIcon = ({ type }) => { 8 | let path; 9 | if (type === 'link' || type === 'info') { 10 | path = ; 11 | } else if (type === 'danger' || type === 'warning') { 12 | path = ; 13 | } else if (type === 'primary' || type === 'success') { 14 | path = ; 15 | } 16 | return ( 17 | 23 | {path} 24 | 25 | ); 26 | }; 27 | 28 | StatusIcon.propTypes = { 29 | type: PropTypes.oneOf([ 30 | 'link', 31 | 'info', 32 | 'danger', 33 | 'warning', 34 | 'success', 35 | 'primary', 36 | ]).isRequired, 37 | }; 38 | 39 | export default StatusIcon; 40 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 13 | 14 | ## Summary 15 | 16 | 17 | 18 | ## Test Plan 19 | 20 | -------------------------------------------------------------------------------- /src/components/Layout/index.jsx: -------------------------------------------------------------------------------- 1 | import { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import * as style from './style.sass'; 4 | import Header from '../Header'; 5 | import SpinLoader from 'components/SpinLoader'; 6 | 7 | class Layout extends Component { 8 | constructor(props) { 9 | super(props); 10 | this.state = { 11 | loading: true, 12 | }; 13 | } 14 | componentDidMount() { 15 | // keep it in class component because of the timing of effect is better than hook, https://reactjs.org/docs/hooks-reference.html#timing-of-effects 16 | this.setState({ 17 | loading: false, 18 | }); 19 | } 20 | render() { 21 | const { 22 | state: { loading }, 23 | } = this; 24 | const { children } = this.props; 25 | return ( 26 |
    27 |
    28 | {loading ? : children} 29 |
    30 | ); 31 | } 32 | } 33 | 34 | Layout.propTypes = { 35 | children: PropTypes.node, 36 | }; 37 | 38 | export default Layout; 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [ColorBro.com](https://www.colorbro.com) v1 ![badge](https://github.com/im6/vp/actions/workflows/ci.yml/badge.svg) 2 | 3 |

    4 | 5 | = 6 | 7 | + 8 | 9 | + 10 | 11 |

    12 | 13 | ## Highlight 14 | 15 | - React.js 16 | - [RxJS](http://reactivex.io/) 17 | - Express.js 18 | - [GraphQL](https://graphql.org/) 19 | - [SSR](https://reactjs.org/docs/react-dom-server.html) 20 | - [Bulma](https://bulma.io/) 21 | - Docker 22 | - Redis 23 | - Google App Engine 24 | - [React Testing Library](https://testing-library.com/) 25 | - MongoDB 26 | - Redux 27 | - react-router 28 | - Webpack 29 | - Babel 30 | - SaSS 31 | - oAuth2 32 | -------------------------------------------------------------------------------- /src/client/modules/color/components/Color/selected.spec.jsx: -------------------------------------------------------------------------------- 1 | import { render } from 'test-utils'; 2 | import Color from '.'; 3 | import { colorDefSample } from '../../../../../testing/dataMock'; 4 | 5 | jest.mock('react-router-dom', () => ({ 6 | ...jest.requireActual('react-router-dom'), // use actual for all non-hook parts 7 | useParams: () => ({ 8 | id: '1', 9 | }), 10 | })); 11 | 12 | describe('render properly', () => { 13 | beforeAll(() => { 14 | global.scrollTo = jest.fn(); 15 | }); 16 | 17 | const liked = { 1: true, 2: true }; 18 | const colorDef = colorDefSample; 19 | const ids = Object.keys(colorDefSample); 20 | const cb = jest.fn(); 21 | test('render with selected color', () => { 22 | const { getByText } = render( 23 | 33 | ); 34 | expect(getByText('Download')).toBeTruthy(); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /webpack/plugins/ServerStartPlugin.js: -------------------------------------------------------------------------------- 1 | /* eslint no-console: 0 */ 2 | const { spawn } = require('child_process'); 3 | 4 | class ServerStartPlugin { 5 | static onStdOut(data) { 6 | const time = new Date().toTimeString(); 7 | process.stdout.write(time.replace(/.*(\d{2}:\d{2}:\d{2}).*/, '[$1] ')); 8 | process.stdout.write(data); 9 | } 10 | 11 | constructor(serverEntry) { 12 | this.child = null; 13 | this.serverEntry = serverEntry; 14 | } 15 | 16 | apply(compiler) { 17 | compiler.hooks.done.tapAsync('ServerStartHook', (cp, callback) => { 18 | if (this.child) { 19 | this.child.kill('SIGTERM'); 20 | } 21 | this.child = spawn('node', [this.serverEntry], { 22 | env: { 23 | ...process.env, 24 | }, 25 | silent: false, 26 | }); 27 | console.log('[server]: start server'); 28 | this.child.stdout.on('data', ServerStartPlugin.onStdOut); 29 | this.child.stderr.on('data', (x) => process.stderr.write(x)); 30 | callback(); 31 | }); 32 | } 33 | } 34 | 35 | module.exports = ServerStartPlugin; 36 | -------------------------------------------------------------------------------- /src/util/index.spec.js: -------------------------------------------------------------------------------- 1 | import { isColorHex, isFourDiff, isValidColorStr } from '.'; 2 | 3 | describe('util test cases', () => { 4 | test('isColorHex', () => { 5 | expect(isColorHex()).toBe(false); 6 | expect(isColorHex('')).toBe(false); 7 | expect(isColorHex(null)).toBe(false); 8 | expect(isColorHex('###')).toBe(false); 9 | expect(isColorHex('#1d8696#22646f#2f5a60#ff8193')).toBe(true); 10 | expect(isColorHex('#1D8696#22646f#2f5a60#ff8193')).toBe(false); 11 | expect(isColorHex('#1da#22646f#2f5a60#ff8193')).toBe(false); 12 | expect(isColorHex('#1da#22646f#2f5a60#ff8193#')).toBe(false); 13 | expect(isColorHex('#1da#22646f#2f5a60#ff8193#ff8193')).toBe(false); 14 | }); 15 | test('isFourDiff', () => { 16 | expect(isFourDiff('#1d8696#22646f#2f5a60#ff8193')).toBe(true); 17 | expect(isFourDiff('#1d8696#22646f#ff8193#ff8193')).toBe(false); 18 | }); 19 | test('isValidColorStr', () => { 20 | expect(isValidColorStr('#1D8696#22646f#2f5a60#ff8193')).toBe(false); 21 | expect(isValidColorStr('#1d8696#22646f#ff8193#ff8193')).toBe(false); 22 | expect(isValidColorStr('#1d8696#22646f#2f5a60#ff8193')).toBe(true); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/client/modules/color/components/Color/notfound.spec.jsx: -------------------------------------------------------------------------------- 1 | import { render } from 'test-utils'; 2 | import Color from '.'; 3 | import { translation } from '../../../../../translation/index'; 4 | import { colorDefSample } from '../../../../../testing/dataMock'; 5 | 6 | const undefinedId = '23'; 7 | 8 | jest.mock('react-router-dom', () => ({ 9 | ...jest.requireActual('react-router-dom'), // use actual for all non-hook parts 10 | useParams: () => ({ 11 | id: undefinedId, 12 | }), 13 | })); 14 | 15 | describe('render properly', () => { 16 | beforeAll(() => { 17 | global.scrollTo = jest.fn(); 18 | }); 19 | 20 | const liked = { 1: true, 2: true }; 21 | const colorDef = colorDefSample; 22 | const cb = jest.fn(); 23 | test('render undefined color id', () => { 24 | const { getByText } = render( 25 | 35 | ); 36 | 37 | expect( 38 | getByText(`${translation.en.undefinedColorId} (${undefinedId})`) 39 | ).toBeTruthy(); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/reducers/user.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | import { createAction, createReducer } from '@reduxjs/toolkit'; 3 | 4 | const initialState = { 5 | detail: null, 6 | weiboUrl: null, 7 | githubUrl: null, 8 | facebookUrl: null, 9 | loading: false, 10 | }; 11 | 12 | const user = createReducer(initialState, (builder) => { 13 | builder 14 | .addCase(createAction('user/auth'), (state) => { 15 | state.loading = true; 16 | }) 17 | .addCase(createAction('user/auth/success'), (state, action) => { 18 | state.loading = false; 19 | state.detail = action.payload; 20 | }) 21 | .addCase(createAction('user/auth/fail'), (state) => { 22 | state.loading = false; 23 | state.detail = null; 24 | }) 25 | .addCase(createAction('user/logoff'), (state) => { 26 | state.detail = null; 27 | }) 28 | .addCase(createAction('user/logoff/success'), (state, action) => { 29 | const { weiboUrl, githubUrl, facebookUrl } = action.payload; 30 | state.detail = null; 31 | state.weiboUrl = weiboUrl; 32 | state.githubUrl = githubUrl; 33 | state.facebookUrl = facebookUrl; 34 | }); 35 | }); 36 | 37 | export default user; 38 | -------------------------------------------------------------------------------- /src/server/resource/redisSession.js: -------------------------------------------------------------------------------- 1 | import { createClient } from 'redis'; 2 | import session from 'express-session'; 3 | import { RedisStore } from 'connect-redis'; 4 | 5 | import { SESSION_SECRET } from '../constant.server'; 6 | 7 | const { 8 | REDIS_HOST: host, 9 | REDIS_PORT: port, 10 | REDIS_PASSWORD: password, 11 | } = process.env; 12 | 13 | if (!host || !port || !password) { 14 | console.error('missing redis connection info'); // eslint-disable-line no-console 15 | process.exit(1); 16 | } 17 | 18 | const client = createClient({ 19 | url: `redis://:${password}@${host}:${port}`, 20 | }); 21 | 22 | client.on('error', (error) => { 23 | console.error(error); // eslint-disable-line no-console 24 | process.exit(1); 25 | }); 26 | 27 | client.on('reconnecting', (_, attmpt) => { 28 | console.log(`reconnecting, attempt: ${attmpt}`); // eslint-disable-line no-console 29 | }); 30 | 31 | client.on('connect', () => { 32 | console.log('Redis connect successfully.'); // eslint-disable-line no-console 33 | }); 34 | 35 | client.connect(); 36 | 37 | export default session({ 38 | store: new RedisStore({ client }), 39 | secret: SESSION_SECRET, 40 | resave: false, 41 | saveUninitialized: false, 42 | }); 43 | -------------------------------------------------------------------------------- /src/client/modules/newcolor/components/EditCanvas/style.sass: -------------------------------------------------------------------------------- 1 | $cornerValue: 7px 2 | $cornerValueInner: 3px 3 | .box 4 | height: 244px 5 | width: 100% 6 | border: 1px solid #cccccc 7 | border-radius: $cornerValue 8 | display: flex 9 | flex-direction: column 10 | align-items: center 11 | justify-content: center 12 | box-shadow: 0 0 3px 0.6px #d9d9d9 13 | transition: box-shadow 0.3s ease-in-out 14 | padding: 2px 15 | 16 | &:hover 17 | box-shadow: 0 1px 18px 2px #cccccc 18 | 19 | 20 | .boxCanvas 21 | width: 100% 22 | height: 100% 23 | display: flex 24 | flex-direction: column 25 | 26 | div:nth-child(1) 27 | border-radius: $cornerValueInner $cornerValueInner 0px 0px 28 | background-color: rgb(52, 43, 43) 29 | height: 25% 30 | 31 | 32 | div:nth-child(2) 33 | background-color: rgb(69, 157, 114) 34 | height: 25% 35 | 36 | 37 | div:nth-child(3) 38 | background-color: rgb(144, 210, 109) 39 | height: 25% 40 | 41 | 42 | div:nth-child(4) 43 | background-color: rgb(222, 251, 194) 44 | height: 25% 45 | border-radius: 0px 0px $cornerValueInner $cornerValueInner 46 | 47 | 48 | 49 | 50 | 51 | 52 | -------------------------------------------------------------------------------- /src/client/routes/index.jsx: -------------------------------------------------------------------------------- 1 | import { Suspense, lazy } from 'react'; 2 | import { Routes, Route } from 'react-router-dom'; 3 | import Color from '../modules/color'; 4 | import SpinLoader from 'components/SpinLoader'; 5 | 6 | const AsyncAdminPanel = lazy(() => 7 | import(/* webpackChunkName: "adminPanel" */ '../modules/adminPanel') 8 | ); 9 | const AsyncNewColor = lazy(() => 10 | import(/* webpackChunkName: "newColor" */ '../modules/newcolor') 11 | ); 12 | 13 | const AppRoutes = () => ( 14 | }> 15 | 16 | } /> 17 | } /> 18 | } /> 19 | } /> 20 | } /> 21 | } /> 22 | } /> 23 | } /> 24 | 25 | 26 | ); 27 | 28 | export default AppRoutes; 29 | -------------------------------------------------------------------------------- /src/components/SpinLoader/style.sass: -------------------------------------------------------------------------------- 1 | .spinContainer 2 | height: 60px 3 | display: flex 4 | justify-content: center 5 | align-items: center 6 | 7 | @keyframes spinLoader-spin 8 | 0% 9 | transform: rotate(0deg) 10 | 11 | 100% 12 | transform: rotate(-2160deg) 13 | 14 | @keyframes spinLoader-normal-opacity 15 | 0% 16 | opacity: 1 17 | 18 | 45% 19 | opacity: 0 20 | 21 | 55% 22 | opacity: 0 23 | 24 | 100% 25 | opacity: 1 26 | 27 | .spinLoader 28 | animation: 1.25s spinLoader-spin cubic-bezier(0.46, -0.4, 0.2, 1.51) infinite both 29 | width: 40px 30 | height: 40px 31 | border-radius: 50% 32 | overflow: hidden 33 | 34 | &:before, &:after 35 | content: "" 36 | position: absolute 37 | width: 100% 38 | height: 100% 39 | border-radius: 50% 40 | top: 0 41 | left: 0 42 | box-sizing: border-box 43 | border: 20px solid black 44 | border-top-color: #fa4248 45 | border-left-color: #5ddbba 46 | border-bottom-color: #ede670 47 | border-right-color: #64c3f2 48 | 49 | &:before 50 | filter: blur(2px) 51 | 52 | &:after 53 | animation: 1.25s spinLoader-normal-opacity cubic-bezier(0.46, -0.4, 0.2, 1.51) infinite both 54 | -------------------------------------------------------------------------------- /src/server/constant.server.js: -------------------------------------------------------------------------------- 1 | import { codeCdnUrl } from '../constant'; 2 | 3 | if (process.env.NODE_ENV === 'development') { 4 | // eslint-disable-next-line global-require 5 | require('dotenv').config({ 6 | path: '../vp.env', 7 | }); 8 | } 9 | 10 | const { env } = process; 11 | 12 | export const { 13 | PORT, 14 | SESSION_SECRET, 15 | CSRF_EXCEPTION, 16 | 17 | FB_API_URL, 18 | FB_APP_KEY, 19 | FB_APP_SECRET, 20 | FB_REDIRECT_URL, 21 | 22 | WB_API_URL, 23 | WB_APP_KEY, 24 | WB_APP_SECRET, 25 | WB_REDIRECT_URL, 26 | 27 | GH_API_URL, 28 | GH_APP_KEY, 29 | GH_APP_SECRET, 30 | GH_REDIRECT_URL, 31 | } = env; 32 | 33 | export const STATIC_URL = 34 | process.env.NODE_ENV === 'development' ? 'local' : 'dist'; 35 | export const PUBLIC_PATH = 36 | process.env.NODE_ENV === 'development' ? '/static' : codeCdnUrl; 37 | 38 | export const SERVER_STATIC_PATH = `./${STATIC_URL}/server`; 39 | export const SERVER_META_FILES = [ 40 | '/robots.txt', 41 | '/sitemap.xml', 42 | '/favicon.ico', 43 | ]; 44 | 45 | if (process.env.NODE_ENV !== 'development' && !CSRF_EXCEPTION) { 46 | // eslint-disable-next-line no-console 47 | console.log('No CSRF exception defined'); 48 | } 49 | if (!PORT) { 50 | // eslint-disable-next-line no-console 51 | console.error('No port defined.'); 52 | process.exit(1); 53 | } 54 | -------------------------------------------------------------------------------- /src/testing/test-utils.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import { Provider } from 'react-redux'; 3 | import { render } from '@testing-library/react'; 4 | import { MemoryRouter } from 'react-router-dom'; 5 | 6 | import { LayoutProvider } from '../contexts/Layout/index'; 7 | import { LanguageProvider } from '../contexts/Language/index'; 8 | import store from './testStore'; 9 | import { defaultLanguageKey, canvasDefaultVertical } from '../constant'; 10 | 11 | jest.mock('../components/Layout/index', () => ({ children }) => ( 12 |
    {children}
    13 | )); 14 | 15 | const AllTheProviders = ({ children }) => { 16 | return ( 17 | 18 | 19 | 20 | 24 | {children} 25 | 26 | 27 | 28 | 29 | ); 30 | }; 31 | 32 | AllTheProviders.propTypes = { 33 | children: PropTypes.element, 34 | }; 35 | 36 | const customRender = (ui, options) => 37 | render(ui, { wrapper: AllTheProviders, ...options }); 38 | 39 | export * from '@testing-library/react'; 40 | export { customRender as render }; 41 | -------------------------------------------------------------------------------- /src/client/modules/color/index.js: -------------------------------------------------------------------------------- 1 | import { createAction } from '@reduxjs/toolkit'; 2 | import { connect } from 'react-redux'; 3 | import Color from './components/Color'; 4 | import { share } from '../../misc/util'; 5 | 6 | const mapStateToProps = ({ color, user }, { source }) => { 7 | const isAuth = Boolean(user.detail); 8 | const { colorDef, liked } = color; 9 | 10 | const list = source === 'saved' ? Object.keys(liked) : color[source]; 11 | 12 | const loading = 13 | (isAuth && source === 'saved') || source === 'colorIdByMyOwn' 14 | ? color.loading || user.loading 15 | : color.loading; 16 | 17 | return { 18 | isAuth, 19 | loading, 20 | list, 21 | colorDef, 22 | liked, 23 | }; 24 | }; 25 | 26 | const mapDispatchToProps = (dispatch) => ({ 27 | onLike({ id, willLike }, isAuth) { 28 | const ac = createAction('color/toggleLike'); 29 | dispatch( 30 | ac({ 31 | isAuth, // used client like manager only 32 | willLike, 33 | id, 34 | }) 35 | ); 36 | }, 37 | onDownload(id, color) { 38 | const ac = createAction('color/download'); 39 | dispatch(ac({ id, color })); 40 | }, 41 | onShare(type) { 42 | share(type); 43 | }, 44 | onCopy(txt) { 45 | const ac = createAction('color/copy'); 46 | dispatch(ac(txt)); 47 | }, 48 | }); 49 | 50 | export default connect(mapStateToProps, mapDispatchToProps)(Color); 51 | -------------------------------------------------------------------------------- /src/reducers/admin.spec.js: -------------------------------------------------------------------------------- 1 | import adminReducer from './admin'; 2 | 3 | describe('test user reducer behavior', () => { 4 | test('action of admin/getList', () => { 5 | const newState = adminReducer( 6 | { loading: false }, 7 | { 8 | type: 'admin/getList', 9 | } 10 | ); 11 | expect(newState.loading).toBeTruthy(); 12 | }); 13 | test('action of admin/getList/success', () => { 14 | const list = [{}, {}]; 15 | expect( 16 | adminReducer( 17 | { loading: true, list: null }, 18 | { 19 | type: 'admin/getList/success', 20 | payload: list, 21 | } 22 | ) 23 | ).toEqual({ 24 | loading: false, 25 | list, 26 | }); 27 | }); 28 | test('action of admin/getList/fail', () => { 29 | const list = [{}, {}]; 30 | expect( 31 | adminReducer( 32 | { loading: {}, list: null }, 33 | { 34 | type: 'admin/getList/fail', 35 | payload: list, 36 | } 37 | ) 38 | ).toEqual({ 39 | loading: false, 40 | list: [], 41 | }); 42 | }); 43 | test('action of admin/decideColor', () => { 44 | const list = [{ id: 1 }, { id: 2 }]; 45 | const selectedId = list[0].id; 46 | expect( 47 | adminReducer( 48 | { list }, 49 | { 50 | type: 'admin/decideColor', 51 | payload: { id: selectedId }, 52 | } 53 | ) 54 | ).toEqual({ 55 | list: list.filter((v) => v.id !== selectedId), 56 | }); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /src/client/modules/color/components/Box/components/ColorCanvas/style.sass: -------------------------------------------------------------------------------- 1 | @import "../../../../../../../constant.sass" 2 | 3 | .boxCanvas 4 | height: $canvas-height-style-0 5 | padding: 0 6 | margin: 0 7 | cursor: pointer 8 | 9 | &>li 10 | list-style-type: none 11 | &:nth-child(1) 12 | border-radius: $canvas-border-radius $canvas-border-radius 0px 0px 13 | height: 40% 14 | 15 | &:nth-child(2) 16 | height: 25% 17 | 18 | &:nth-child(3) 19 | height: 17.5% 20 | 21 | &:nth-child(4) 22 | height: 17.5% 23 | border-radius: 0px 0px $canvas-border-radius $canvas-border-radius 24 | &>span 25 | font-size: 1.2rem // font size different with .boxCanvas1 26 | 27 | .boxCanvas1 28 | height: $canvas-height-style-1 29 | padding: 0 30 | margin: 0 31 | cursor: pointer 32 | display: flex 33 | &>li 34 | list-style-type: none 35 | width: 25% 36 | height: 100% 37 | &:nth-child(1) 38 | border-radius: $canvas-border-radius 0px 0px $canvas-border-radius 39 | &:nth-child(4) 40 | border-radius: 0px $canvas-border-radius $canvas-border-radius 0px 41 | &>span 42 | font-size: 0.75rem 43 | writing-mode: vertical-rl 44 | text-orientation: upright 45 | -webkit-text-orientation: upright 46 | 47 | @media only screen and (min-device-width : 375px) and (max-device-width : 812px) and (orientation : portrait) 48 | .boxCanvas 49 | height: $canvas-height-style-0-mobile 50 | .boxCanvas1 51 | height: $canvas-height-style-1-mobile 52 | -------------------------------------------------------------------------------- /src/server/middlewares/graphql/schema.js: -------------------------------------------------------------------------------- 1 | import { buildSchema } from 'graphql'; 2 | 3 | const schemaStr = ` 4 | enum ColorCategory { 5 | PUBLIC 6 | ANONYMOUS 7 | } 8 | 9 | interface MutationResponse { 10 | status: Int! 11 | } 12 | 13 | type Color { 14 | id: ID! 15 | star: Int! 16 | color: String! 17 | userId: ID 18 | username: String 19 | createdDate: String 20 | } 21 | 22 | type User { 23 | img: String 24 | isAdmin: Boolean 25 | name: String 26 | likes: [ID!] 27 | owns: [ID!] 28 | } 29 | 30 | input LikeColorInputType { 31 | id: ID! 32 | willLike: Boolean! 33 | } 34 | 35 | input CreateColorInputType { 36 | color: String! 37 | } 38 | 39 | type LikeColorOutputType implements MutationResponse { 40 | status: Int! 41 | } 42 | 43 | type AdjudicateColorOutputType implements MutationResponse { 44 | status: Int! 45 | } 46 | 47 | type CreateColorOutputType implements MutationResponse { 48 | status: Int! 49 | data: ID! 50 | } 51 | 52 | type Mutation { 53 | likeColor(input: LikeColorInputType!): LikeColorOutputType 54 | createColor(input: CreateColorInputType!): CreateColorOutputType 55 | adjudicateColor(input: LikeColorInputType!): AdjudicateColorOutputType 56 | } 57 | 58 | type Query { 59 | color(category: ColorCategory!): [Color] 60 | user: User 61 | } 62 | 63 | schema { 64 | query: Query 65 | mutation: Mutation 66 | } 67 | `; 68 | 69 | const schema = buildSchema(schemaStr); 70 | export default schema; 71 | -------------------------------------------------------------------------------- /webpack/base.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const nodeExternals = require('webpack-node-externals'); 3 | 4 | const localIdentName = '[hash:base64:5]'; 5 | const include = path.resolve(__dirname, '../src'); 6 | 7 | const resolve = { 8 | extensions: ['.js', '.jsx'], 9 | alias: { 10 | components: path.resolve(__dirname, '../src/components'), 11 | }, 12 | }; 13 | 14 | const sass = { 15 | loader: 'sass-loader', 16 | }; 17 | 18 | exports.withoutCssModuleFiles = [ 19 | /src(\\|\/)client(\\|\/)bulma.modules.sass/, 20 | /components(\\|\/)SpinLoader(\\|\/)style.sass/, 21 | ]; 22 | 23 | exports.clientBaseConfig = { 24 | resolve, 25 | entry: path.join(__dirname, '../src/client'), 26 | }; 27 | 28 | exports.serverBaseConfig = { 29 | target: 'node', 30 | resolve, 31 | externals: [nodeExternals()], 32 | entry: path.join(__dirname, '../src/server'), 33 | }; 34 | 35 | exports.include = include; 36 | exports.localIdentName = localIdentName; 37 | exports.staticAssetsPath = 'assets/static'; 38 | 39 | exports.sassLoader = sass; 40 | 41 | exports.serverModule = { 42 | rules: [ 43 | { 44 | test: /\.jsx?$/, 45 | include, 46 | use: ['babel-loader'], 47 | }, 48 | { 49 | test: /\.sass$/, 50 | include, 51 | use: [ 52 | { 53 | loader: 'css-loader', 54 | options: { 55 | modules: { 56 | localIdentName, 57 | exportOnlyLocals: true, 58 | }, 59 | }, 60 | }, 61 | sass, 62 | ], 63 | }, 64 | ], 65 | }; 66 | -------------------------------------------------------------------------------- /src/client/modules/color/components/Box/index.spec.jsx: -------------------------------------------------------------------------------- 1 | import { render, fireEvent } from 'test-utils'; 2 | import Box from '.'; 3 | import { boxInfo } from '../../../../../testing/dataMock'; 4 | 5 | describe('render properly', () => { 6 | const clickCb = jest.fn(); 7 | 8 | test('render properly with click', () => { 9 | const { container, getByText, rerender } = render( 10 | 20 | ); 21 | rerender( 22 | 32 | ); 33 | 34 | rerender( 35 | 45 | ); 46 | 47 | fireEvent.click(getByText('Red Heart')); 48 | fireEvent.click(container.querySelector('ul')); 49 | fireEvent.click(getByText(`#${boxInfo.color.split('#')[0]}`)); 50 | 51 | expect(clickCb).toBeCalled(); 52 | expect(container.querySelectorAll('li')).toHaveLength(4); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | paths: 6 | - '.github/**' 7 | - 'src/**' 8 | - 'webpack/**' 9 | - 'package.json' 10 | branches: 11 | - master 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | node-version: [20.x] 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | - name: Use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v1 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | - name: cache node modules 27 | uses: actions/cache@v4 28 | with: 29 | path: ~/.npm # npm cache files are stored in `~/.npm` on Linux/macOS 30 | key: ${{ runner.os }}-node-${{ hashFiles('**/yarn.lock') }} 31 | restore-keys: | 32 | ${{ runner.os }}-yarn- 33 | - name: install 34 | run: npm i 35 | - name: lint 36 | run: npm run lint 37 | - name: test 38 | run: npm t 39 | - name: build 40 | if: false 41 | run: npm run build 42 | - name: setup aliyun oss 43 | if: false 44 | uses: manyuanrong/setup-ossutil@master 45 | with: 46 | endpoint: ${{ secrets.oss_region }} 47 | access-key-id: ${{ secrets.oss_app_key }} 48 | access-key-secret: ${{ secrets.oss_app_secret }} 49 | - name: cp files to aliyun 50 | if: false 51 | run: ossutil cp -rf dist/public oss://${{ secrets.oss_bucket }}/2/ --meta=Cache-Control:"public, max-age=2592000"#Content-Encoding:gzip 52 | -------------------------------------------------------------------------------- /src/client/index.jsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react'; 2 | import { langSelectionKey, canvasOrientationKey } from '../constant'; 3 | import { setCookie, customEventPolyFill } from './misc/util'; 4 | import { hydrateRoot } from 'react-dom/client'; 5 | import { Provider } from 'react-redux'; 6 | import store from './config/store'; 7 | import Routes from './routes'; 8 | import { BrowserRouter } from 'react-router-dom'; 9 | import './bulma.modules.sass'; 10 | import Modal from './modules/modal'; 11 | import { LayoutProvider } from '../contexts/Layout/index'; 12 | import { LanguageProvider } from '../contexts/Language/index'; 13 | 14 | hydrateRoot( 15 | document.getElementById('app'), 16 | 17 | 18 | 19 | { 22 | setCookie(langSelectionKey, l); 23 | }} 24 | > 25 | { 28 | setCookie(canvasOrientationKey, v ? '1' : '0'); 29 | }} 30 | > 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | ); 39 | 40 | store.dispatch({ type: 'color/get' }); 41 | store.dispatch({ type: 'user/auth' }); 42 | 43 | if (process.env.NODE_ENV !== 'development') { 44 | customEventPolyFill(); 45 | window.dispatchEvent(new CustomEvent('_COLORBRO_SCRIPT_READY')); 46 | console.log('client last build: ', process.env.lastBuildDate); 47 | } 48 | -------------------------------------------------------------------------------- /src/client/modules/adminPanel/AdminPanel/index.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import * as style from './style.sass'; 4 | import Box from '../../color/components/Box'; 5 | import useLayoutContext from '../../../../hooks/useLayoutContext'; 6 | 7 | const AdminPanel = ({ loading, list, onAdjudicate, onInitList }) => { 8 | const [isVertical] = useLayoutContext(); 9 | 10 | useEffect(() => { 11 | if (!list) onInitList(); 12 | }, []); 13 | 14 | const onAdjudicateLocal = (id, willLike) => () => onAdjudicate(id, willLike); 15 | 16 | return ( 17 |
    18 | {!loading && 19 | (list && list.length > 0 ? ( 20 | list.map((v) => ( 21 |
    22 | 30 |
    31 |    32 | 38 |
    39 | )) 40 | ) : ( 41 |

    No colors to decide.

    42 | ))} 43 |
    44 | ); 45 | }; 46 | 47 | AdminPanel.propTypes = { 48 | list: PropTypes.array, 49 | loading: PropTypes.bool, 50 | onInitList: PropTypes.func.isRequired, 51 | onAdjudicate: PropTypes.func.isRequired, 52 | }; 53 | 54 | export default AdminPanel; 55 | -------------------------------------------------------------------------------- /src/components/OpenGraph.jsx: -------------------------------------------------------------------------------- 1 | import { Fragment } from 'react'; 2 | 3 | const ogTitle = 'ColorBro v1'; 4 | const ogSiteName = 'colorbro'; 5 | const ogDescription = 6 | 'Your Best Color Picker | 全球最大色彩搭配网站 | 颜色搭配 | 艺术设计'; 7 | const ogImgHeight = 640; 8 | const ogImgWidth = 1280; 9 | const ogImage = 10 | 'https://repository-images.githubusercontent.com/75897824/b1278e80-8704-11ea-9acf-ac166e4ad4fd'; 11 | 12 | const OgMeta = () => ( 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | {/* */} 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | ); 37 | 38 | export default OgMeta; 39 | -------------------------------------------------------------------------------- /src/client/misc/likeManager.js: -------------------------------------------------------------------------------- 1 | import { localStorageEnabled } from './util'; 2 | 3 | const LSLIKEKEY = 'userLike'; 4 | 5 | class LikeManagement { 6 | constructor() { 7 | this.hasLocalStorage = localStorageEnabled; 8 | if (localStorageEnabled) { 9 | // todo: clean up in the near future 10 | LikeManagement.cleanUpType(); 11 | } 12 | this.initLikes = this.getInitLike(); 13 | } 14 | 15 | static cleanUpType() { 16 | const userLike = JSON.parse(window.localStorage.getItem(LSLIKEKEY)); 17 | if (Array.isArray(userLike)) { 18 | const strIds = userLike.filter((v) => typeof v === 'string'); 19 | window.localStorage.setItem(LSLIKEKEY, JSON.stringify(strIds)); 20 | } 21 | } 22 | 23 | getInitLike() { 24 | if (this.hasLocalStorage) { 25 | const currentLocalState = JSON.parse( 26 | window.localStorage.getItem(LSLIKEKEY) 27 | ); 28 | if (Array.isArray(currentLocalState)) { 29 | return currentLocalState; 30 | } 31 | window.localStorage.setItem(LSLIKEKEY, JSON.stringify([])); 32 | return []; 33 | } 34 | return []; 35 | } 36 | 37 | addLike(id) { 38 | if (this.hasLocalStorage) { 39 | const userLike = JSON.parse(window.localStorage.getItem(LSLIKEKEY)); 40 | userLike.push(id); 41 | window.localStorage.setItem(LSLIKEKEY, JSON.stringify(userLike)); 42 | } 43 | } 44 | 45 | removeLike(id) { 46 | if (this.hasLocalStorage) { 47 | const userLike = JSON.parse(window.localStorage.getItem(LSLIKEKEY)); 48 | const newUserLike = userLike.filter((v) => v !== id); 49 | window.localStorage.setItem(LSLIKEKEY, JSON.stringify(newUserLike)); 50 | } 51 | } 52 | } 53 | 54 | const likeManager = new LikeManagement(); // always singleton 55 | 56 | export default likeManager; 57 | -------------------------------------------------------------------------------- /src/client/modules/color/components/Box/Box.jsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import * as style from './style.sass'; 4 | import HeartButton from './components/HeartButton'; 5 | import ColorCanvas from './components/ColorCanvas'; 6 | 7 | const Box = ({ 8 | id, 9 | value, 10 | starNum, 11 | starred, 12 | username, 13 | vertical, 14 | showUsername, 15 | onClickText = () => {}, 16 | onClickHeart, 17 | onClickCanvas = () => {}, 18 | }) => { 19 | const onClickHeartLocal = () => { 20 | onClickHeart({ 21 | willLike: !starred, 22 | id, 23 | }); 24 | }; 25 | const onClickCanvasLocal = () => { 26 | onClickCanvas(id); 27 | }; 28 | const canvasMemo = useMemo( 29 | () => ( 30 | 36 | ), 37 | [vertical, value] 38 | ); 39 | const btnMemo = useMemo( 40 | () => ( 41 | 46 | ), 47 | [starred, starNum] 48 | ); 49 | return ( 50 |
    51 | {canvasMemo} 52 | {btnMemo} 53 | {showUsername && username &&

    {username}

    } 54 |
    55 | ); 56 | }; 57 | 58 | Box.propTypes = { 59 | id: PropTypes.string.isRequired, 60 | username: PropTypes.string, 61 | starNum: PropTypes.number, 62 | value: PropTypes.string.isRequired, 63 | starred: PropTypes.bool, 64 | vertical: PropTypes.bool, 65 | showUsername: PropTypes.bool, 66 | onClickHeart: PropTypes.func.isRequired, 67 | onClickText: PropTypes.func, 68 | onClickCanvas: PropTypes.func, 69 | }; 70 | 71 | export default Box; 72 | -------------------------------------------------------------------------------- /src/reducers/user.spec.js: -------------------------------------------------------------------------------- 1 | import userReducer from './user'; 2 | 3 | describe('test user reducer behavior', () => { 4 | test('action of user/logoff', () => { 5 | expect( 6 | userReducer( 7 | { detail: {} }, 8 | { 9 | type: 'user/logoff', 10 | } 11 | ) 12 | ).toEqual({ 13 | detail: null, 14 | }); 15 | }); 16 | test('action of user/auth', () => { 17 | expect( 18 | userReducer( 19 | { loading: null }, 20 | { 21 | type: 'user/auth', 22 | } 23 | ) 24 | ).toEqual({ 25 | loading: true, 26 | }); 27 | }); 28 | test('action of user/auth/success', () => { 29 | const userInfo = { name: 'tom' }; 30 | expect( 31 | userReducer( 32 | { detail: {} }, 33 | { 34 | type: 'user/auth/success', 35 | payload: userInfo, 36 | } 37 | ) 38 | ).toEqual({ 39 | detail: userInfo, 40 | loading: false, 41 | }); 42 | }); 43 | test('action of user/auth/fail', () => { 44 | expect( 45 | userReducer( 46 | { detail: {}, loading: true }, 47 | { 48 | type: 'user/auth/fail', 49 | } 50 | ) 51 | ).toEqual({ 52 | detail: null, 53 | loading: false, 54 | }); 55 | }); 56 | test('action of user/logoff/success', () => { 57 | const facebookUrl = 'some fb url'; 58 | const weiboUrl = 'some wb url'; 59 | const githubUrl = 'some gh url'; 60 | const newUrlMap = { 61 | facebookUrl, 62 | weiboUrl, 63 | githubUrl, 64 | }; 65 | expect( 66 | userReducer( 67 | { facebookUrl: null }, 68 | { 69 | type: 'user/logoff/success', 70 | payload: newUrlMap, 71 | } 72 | ) 73 | ).toEqual({ 74 | detail: null, 75 | ...newUrlMap, 76 | }); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /src/client/modules/color/components/ShareWrapper/index.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import * as style from './style.sass'; 3 | import useTranslationContext from '../../../../../hooks/useTranslationContext'; 4 | 5 | const ShareWrapper = ({ id, value, children, onShare, onDownload }) => { 6 | const [language] = useTranslationContext(); 7 | return ( 8 |
    9 |
    10 | {children} 11 |
    12 | 19 |
    20 |
    21 | 30 | 39 | 48 |
    49 |
    50 |
    51 | ); 52 | }; 53 | 54 | ShareWrapper.propTypes = { 55 | id: PropTypes.string.isRequired, 56 | value: PropTypes.string.isRequired, 57 | children: PropTypes.element.isRequired, 58 | onShare: PropTypes.func.isRequired, 59 | onDownload: PropTypes.func.isRequired, 60 | }; 61 | 62 | export default ShareWrapper; 63 | -------------------------------------------------------------------------------- /src/client/epics/admin.js: -------------------------------------------------------------------------------- 1 | import get from 'lodash.get'; 2 | import { ofType } from 'redux-observable'; 3 | import { of, map, switchMap, filter, catchError } from 'rxjs'; 4 | import requester from '../misc/requester'; 5 | 6 | const colorql = `query($cate: ColorCategory!) { 7 | color(category: $cate) { 8 | id 9 | star 10 | color 11 | userId 12 | username 13 | createdDate 14 | } 15 | }`; 16 | 17 | const adjudicateql = `mutation($val: LikeColorInputType!) { 18 | adjudicateColor(input: $val) { 19 | status 20 | } 21 | }`; 22 | 23 | const getListEpic$ = (action$) => 24 | action$.pipe( 25 | ofType('admin/getList'), 26 | switchMap(() => 27 | requester({ 28 | query: colorql, 29 | variables: { cate: 'ANONYMOUS' }, 30 | }).pipe( 31 | map((res) => ({ 32 | type: 'admin/getList/success', 33 | payload: get(res, 'response.data.color', null), 34 | })), 35 | catchError(() => 36 | of( 37 | { 38 | type: 'admin/getList/fail', 39 | }, 40 | { 41 | type: 'modal', 42 | payload: ['danger', 'Admin data error'], 43 | } 44 | ) 45 | ) 46 | ) 47 | ) 48 | ); 49 | 50 | const decideColorEpic$ = (action$) => 51 | action$.pipe( 52 | ofType('admin/decideColor'), 53 | switchMap((action1) => 54 | requester({ 55 | query: adjudicateql, 56 | variables: { 57 | val: action1.payload, 58 | }, 59 | }).pipe( 60 | filter( 61 | (res) => get(res, 'response.data.adjudicateColor.status', 1) === 0 62 | ), 63 | map(() => ({ 64 | type: 'modal', 65 | payload: ['success', 'Adjudicate successfully'], 66 | })), 67 | catchError(() => 68 | of({ 69 | type: 'modal', 70 | payload: ['danger', 'Adjudicate failed'], 71 | }) 72 | ) 73 | ) 74 | ) 75 | ); 76 | 77 | export default [getListEpic$, decideColorEpic$]; 78 | -------------------------------------------------------------------------------- /dist/public/newColor.css: -------------------------------------------------------------------------------- 1 | .MHHag{-webkit-box-orient:vertical;-webkit-box-direction:normal;-moz-box-orient:vertical;-moz-box-direction:normal;-webkit-box-align:center;-moz-box-align:center;-webkit-box-pack:center;-moz-box-pack:center;-webkit-align-items:center;align-items:center;border:1px solid #ccc;border-radius:7px;-webkit-box-shadow:0 0 3px .6px #d9d9d9;box-shadow:0 0 3px .6px #d9d9d9;display:-webkit-box;display:-webkit-flex;display:-moz-box;display:flex;-webkit-flex-direction:column;flex-direction:column;height:244px;-webkit-justify-content:center;justify-content:center;padding:2px;-webkit-transition:-webkit-box-shadow .3s ease-in-out;transition:-webkit-box-shadow .3s ease-in-out;-o-transition:box-shadow .3s ease-in-out;-moz-transition:box-shadow .3s ease-in-out;transition:box-shadow .3s ease-in-out;transition:box-shadow .3s ease-in-out,-webkit-box-shadow .3s ease-in-out;width:100%}.MHHag:hover{-webkit-box-shadow:0 1px 18px 2px #ccc;box-shadow:0 1px 18px 2px #ccc}.MHHag .RW9Z7{-webkit-box-orient:vertical;-webkit-box-direction:normal;-moz-box-orient:vertical;-moz-box-direction:normal;display:-webkit-box;display:-webkit-flex;display:-moz-box;display:flex;-webkit-flex-direction:column;flex-direction:column;height:100%;width:100%}.MHHag .RW9Z7 div:first-child{background-color:#342b2b;border-radius:3px 3px 0 0;height:25%}.MHHag .RW9Z7 div:nth-child(2){background-color:#459d72;height:25%}.MHHag .RW9Z7 div:nth-child(3){background-color:#90d26d;height:25%}.MHHag .RW9Z7 div:nth-child(4){background-color:#defbc2;border-radius:0 0 3px 3px;height:25%}.I7Ibz{-webkit-box-align:end;-moz-box-align:end;-webkit-box-pack:end;-moz-box-pack:end;-webkit-align-items:flex-end;align-items:flex-end;display:-webkit-box;display:-webkit-flex;display:-moz-box;display:flex;-webkit-justify-content:flex-end;justify-content:flex-end}.eT54L{margin:60px auto 0;max-width:375px;width:96%}.eT54L .oPX6k{display:-webkit-box;display:-webkit-flex;display:-moz-box;display:flex}.eT54L .oPX6k>div:first-child{width:65%}.eT54L .oPX6k>div:nth-child(2){width:35%}.eT54L .Kx1ue{display:-webkit-box;display:-webkit-flex;display:-moz-box;display:flex}.eT54L .Kx1ue>button{-webkit-box-flex:1;-moz-box-flex:1;-webkit-flex:1;flex:1;margin:5px} -------------------------------------------------------------------------------- /dist/public/adminPanel.js: -------------------------------------------------------------------------------- 1 | "use strict";(self.webpackChunkvp=self.webpackChunkvp||[]).push([[203],{5929:function(n,t,r){r.r(t),r.d(t,{default:function(){return y}});var e=r(3633),i=r(6113),o=r(7294),u=r(5697),a=r.n(u),l="CmIth",c=r(6098),s=r(6299),d=r(5893);function f(n,t){return function(n){if(Array.isArray(n))return n}(n)||function(n,t){var r=null==n?null:"undefined"!=typeof Symbol&&n[Symbol.iterator]||n["@@iterator"];if(null!=r){var e,i,o,u,a=[],l=!0,c=!1;try{if(o=(r=r.call(n)).next,0===t){if(Object(r)!==r)return;l=!1}else for(;!(l=(e=o.call(r)).done)&&(a.push(e.value),a.length!==t);l=!0);}catch(n){c=!0,i=n}finally{try{if(!l&&null!=r.return&&(u=r.return(),Object(u)!==u))return}finally{if(c)throw i}}return a}}(n,t)||function(n,t){if(!n)return;if("string"==typeof n)return v(n,t);var r=Object.prototype.toString.call(n).slice(8,-1);"Object"===r&&n.constructor&&(r=n.constructor.name);if("Map"===r||"Set"===r)return Array.from(n);if("Arguments"===r||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(r))return v(n,t)}(n,t)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function v(n,t){(null==t||t>n.length)&&(t=n.length);for(var r=0,e=new Array(t);r0?r.map((function(n){return(0,d.jsxs)("div",{children:[(0,d.jsx)(c.Z,{value:n.color,id:n.id,vertical:u,starNum:n.star,starred:!1,onClickHeart:a(n.id,!0)}),(0,d.jsx)("br",{}),"  ",(0,d.jsxs)("button",{className:"button is-danger is-small",onClick:a(n.id,!1),children:["Delete ",n.id]})]},n.id)})):(0,d.jsx)("h1",{children:"No colors to decide."}))})};h.propTypes={list:a().array,loading:a().bool,onInitList:a().func.isRequired,onAdjudicate:a().func.isRequired};var m=h,y=(0,i.$j)((function(n){return n.admin}),(function(n){return{onInitList:function(){var t=(0,e.PH)("admin/getList");n(t())},onAdjudicate:function(t,r){var i=(0,e.PH)("admin/decideColor");n(i({id:t,willLike:r}))}}}))(m)}}]); -------------------------------------------------------------------------------- /src/reducers/color.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | import { createAction, createReducer } from '@reduxjs/toolkit'; 3 | 4 | const initialState = { 5 | loading: false, 6 | 7 | colorDef: {}, 8 | liked: {}, 9 | 10 | colorIdAllByDate: [], 11 | colorIdAllByStar: [], 12 | colorIdByMyOwn: [], 13 | }; 14 | 15 | const color = createReducer(initialState, (builder) => { 16 | builder 17 | .addCase(createAction('color/get'), (state) => { 18 | state.loading = true; 19 | }) 20 | .addCase(createAction('color/get/success'), (state, action) => { 21 | const { payload } = action; 22 | const colorIdAllByDate = []; 23 | const colorDef = {}; 24 | payload.forEach((v) => { 25 | colorIdAllByDate.push(v.id); // default order is by Date 26 | colorDef[v.id] = v; 27 | }); 28 | const colorIdAllByStar = payload 29 | .sort((a, b) => b.star - a.star) 30 | .map((v) => v.id); 31 | 32 | state.loading = false; 33 | state.colorDef = colorDef; 34 | state.colorIdAllByDate = colorIdAllByDate; 35 | state.colorIdAllByStar = colorIdAllByStar; 36 | }) 37 | .addCase(createAction('color/get/fail'), (state) => { 38 | state.loading = false; 39 | state.colorIdAllByDate = []; 40 | state.colorIdAllByStar = []; 41 | }) 42 | .addCase(createAction('color/toggleLike'), (state, action) => { 43 | const { willLike, id } = action.payload; 44 | if (willLike) { 45 | state.liked[id] = true; 46 | } else { 47 | delete state.liked[id]; 48 | } 49 | state.colorDef[id].star += willLike ? 1 : -1; 50 | }) 51 | .addCase(createAction('color/addNew/success'), (state, action) => { 52 | const { payload } = action; 53 | const { id } = payload; 54 | state.colorDef[id] = payload; 55 | state.colorIdAllByDate.unshift(id); 56 | state.colorIdAllByStar.push(id); 57 | }) 58 | .addCase(createAction('color/set/likes'), (state, action) => { 59 | const liked = action.payload.reduce((acc, cur) => { 60 | acc[cur] = true; 61 | return acc; 62 | }, {}); 63 | state.liked = liked; 64 | }) 65 | .addCase(createAction('color/set/owns'), (state, action) => { 66 | state.colorIdByMyOwn = action.payload; 67 | }); 68 | }); 69 | 70 | export default color; 71 | -------------------------------------------------------------------------------- /src/client/modules/color/components/Color/index.spec.jsx: -------------------------------------------------------------------------------- 1 | import { render, fireEvent } from 'test-utils'; 2 | import Color from '.'; 3 | import { translation } from '../../../../../translation/index'; 4 | import { selectedColor, colorDefSample } from '../../../../../testing/dataMock'; 5 | 6 | describe('render properly', () => { 7 | beforeAll(() => { 8 | global.scrollTo = jest.fn(); 9 | }); 10 | 11 | const liked = { 1: true, 2: true }; 12 | const colorDef = colorDefSample; 13 | const ids = Object.keys(colorDefSample); 14 | 15 | const cb = jest.fn(); 16 | 17 | test('render loading', () => { 18 | const { container } = render( 19 | 30 | ); 31 | expect(container.querySelector('.spinContainer')).toBeTruthy(); 32 | }); 33 | test('render no data', () => { 34 | const { getByText } = render( 35 | 45 | ); 46 | 47 | expect(getByText(translation.en.noColorsToShow)).toBeTruthy(); 48 | }); 49 | test('click to enter', () => { 50 | const { container } = render( 51 | 61 | ); 62 | 63 | fireEvent.click(container.querySelector('ul')); 64 | expect(cb).not.toBeCalled(); // trigger history hook event 65 | }); 66 | test('click to like and copy', () => { 67 | const { container, getByText } = render( 68 | 78 | ); 79 | fireEvent.click(container.querySelector('button')); 80 | fireEvent.click(getByText(selectedColor)); 81 | expect(cb).toHaveBeenCalledTimes(2); 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /src/server/app/index.js: -------------------------------------------------------------------------------- 1 | import helmet from 'helmet'; 2 | import express from 'express'; 3 | import { json as bpJson, urlencoded as bpUrlencoded } from 'body-parser'; 4 | import cookieParser from 'cookie-parser'; 5 | import redisSession from '../resource/redisSession'; 6 | import { connect } from '../resource/mongodb/connection'; 7 | 8 | import { SERVER_META_FILES } from '../constant.server'; 9 | 10 | import { oauthLogin, oauthLogout, isAuth, isAdmin } from '../middlewares/auth'; 11 | import { 12 | onError, 13 | onNotFound, 14 | onGcpAppEngSig, 15 | } from '../middlewares/errorHandler'; 16 | import csrfOverride from '../middlewares/csrfHandler'; 17 | import graphqlMiddleware from '../middlewares/graphql'; 18 | import ssrMiddleware from '../middlewares/render'; 19 | 20 | const app = express(); 21 | const publicUrls = ['/', '/latest', '/popular', '/color/:id', '/new', '/like']; 22 | 23 | connect(); 24 | 25 | if (process.env.NODE_ENV !== 'development') { 26 | app.set('trust proxy', true); 27 | } 28 | 29 | app.use( 30 | helmet({ 31 | contentSecurityPolicy: false, 32 | crossOriginEmbedderPolicy: false, 33 | }) 34 | ); 35 | app.use(bpJson()); 36 | app.use(bpUrlencoded({ extended: false })); 37 | app.use(cookieParser()); 38 | app.use(redisSession); 39 | 40 | if (process.env.NODE_ENV === 'development') { 41 | app.use('/static', express.static('local/public')); 42 | app.use((req, res, next) => { 43 | // eslint-disable-next-line no-console 44 | console.log(`${req.method}: ${req.originalUrl}`); 45 | next(); 46 | }); 47 | } else { 48 | // eslint-disable-next-line global-require 49 | const staticFileProd = require('../middlewares/staticFile').default; 50 | SERVER_META_FILES.forEach((v) => { 51 | app.get(v, staticFileProd); 52 | }); 53 | app.get('/_ah/:action', onGcpAppEngSig); // gcp status actuator 54 | app.use(csrfOverride); 55 | } 56 | 57 | app[process.env.NODE_ENV === 'development' ? 'use' : 'post']( 58 | '/graphql', 59 | graphqlMiddleware 60 | ); 61 | if (process.env.NODE_ENV === 'development') { 62 | // GraphiQL doesn't go through csrf 63 | app.use(csrfOverride); 64 | } 65 | 66 | app.get('/auth/logout', oauthLogout); 67 | app.get('/auth/:oauth', oauthLogin); 68 | 69 | publicUrls.forEach((url) => { 70 | app.get(url, ssrMiddleware); 71 | }); 72 | 73 | app.get('/portfolio', isAuth, ssrMiddleware); 74 | app.get('/adminpanel', isAdmin, ssrMiddleware); 75 | 76 | app.use(onNotFound); 77 | app.use(onError); 78 | 79 | export default app; 80 | -------------------------------------------------------------------------------- /src/server/middlewares/auth.js: -------------------------------------------------------------------------------- 1 | /* eslint no-console: 0 */ 2 | import get from 'lodash.get'; 3 | import { v1 as uuidV1 } from 'uuid'; 4 | import { 5 | fetchFacebookToken, 6 | fetchWeiboToken, 7 | fetchGithubToken, 8 | createLoginLink, 9 | } from '../resource/oauth'; 10 | import { isAuth as isAuthHelper, isAdmin as isAdminHelper } from '../helper'; 11 | 12 | export const oauthLogin = async (req, res) => { 13 | const code = get(req, 'query.code', null); 14 | const state = get(req, 'query.state', null); 15 | const oauth = get(req, 'params.oauth', null); 16 | const oauthState = get(req, 'session.app.oauthState', null); 17 | 18 | if (code && state && state === oauthState) { 19 | console.log(`redirected by ${oauth} auth...`); 20 | let tokenInfo = null; 21 | if (oauth === 'fb') { 22 | tokenInfo = await fetchFacebookToken(code); 23 | } else if (oauth === 'wb') { 24 | tokenInfo = await fetchWeiboToken(code); 25 | } else if (oauth === 'gh') { 26 | tokenInfo = await fetchGithubToken(code); 27 | } else { 28 | throw new Error(); 29 | } 30 | 31 | if (tokenInfo) { 32 | req.session.app = { 33 | oauth, 34 | isAuth: true, 35 | tokenInfo, 36 | }; 37 | } else { 38 | req.session.app = { 39 | isAuth: false, 40 | authError: 'get token failed.', 41 | }; 42 | } 43 | } else { 44 | console.log('inconsistant session, error msg in session'); 45 | req.session.app = { 46 | isAuth: false, 47 | authError: 'Sorry, something error, please try again.', 48 | }; 49 | } 50 | res.redirect('/'); 51 | }; 52 | 53 | export const oauthLogout = (req, res) => { 54 | const username = get(req, 'session.app.dbInfo.name', 'unknown'); 55 | console.log(`logoff (${username}), delete session`); // eslint-disable-line no-console 56 | delete req.session.app; 57 | 58 | const oauthState = uuidV1(); 59 | req.session.app = { 60 | oauthState, 61 | }; 62 | 63 | res.json({ 64 | weiboUrl: createLoginLink('wb', oauthState), 65 | githubUrl: createLoginLink('gh', oauthState), 66 | facebookUrl: 67 | process.env.NODE_ENV === 'development' 68 | ? createLoginLink('fb', oauthState) 69 | : null, 70 | }); 71 | }; 72 | 73 | export const isAuth = (req, res, next) => { 74 | if (isAuthHelper(req)) { 75 | next(); 76 | } else { 77 | next(401); 78 | } 79 | }; 80 | 81 | export const isAdmin = (req, res, next) => { 82 | if (isAdminHelper(req)) { 83 | next(); 84 | } else { 85 | next(403); 86 | } 87 | }; 88 | -------------------------------------------------------------------------------- /webpack/develop.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { CleanWebpackPlugin } = require('clean-webpack-plugin'); 3 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 4 | const CopyPlugin = require('copy-webpack-plugin'); 5 | const ServerStartPlugin = require('./plugins/ServerStartPlugin'); 6 | 7 | const { 8 | withoutCssModuleFiles, 9 | clientBaseConfig, 10 | serverBaseConfig, 11 | localIdentName, 12 | staticAssetsPath, 13 | include, 14 | sassLoader, 15 | serverModule, 16 | } = require('./base'); 17 | 18 | const devBase = { 19 | watch: true, 20 | mode: 'development', 21 | devtool: 'inline-source-map', 22 | watchOptions: { 23 | ignored: /node_modules/, 24 | }, 25 | }; 26 | 27 | const client = Object.assign(clientBaseConfig, devBase, { 28 | optimization: { 29 | splitChunks: { 30 | chunks: 'async', 31 | cacheGroups: { 32 | default: false, 33 | defaultVendors: false, 34 | }, 35 | }, 36 | }, 37 | output: { 38 | publicPath: '/static/', 39 | path: path.join(__dirname, '../local/public'), 40 | filename: '[name].js', 41 | }, 42 | module: { 43 | rules: [ 44 | { 45 | test: /\.jsx?$/, 46 | include, 47 | use: ['babel-loader'], 48 | }, 49 | { 50 | test: /\.sass$/, 51 | include: withoutCssModuleFiles, 52 | use: [ 53 | MiniCssExtractPlugin.loader, 54 | { 55 | loader: 'css-loader', 56 | options: { 57 | modules: false, 58 | }, 59 | }, 60 | sassLoader, 61 | ], 62 | }, 63 | { 64 | test: /\.sass$/, 65 | exclude: withoutCssModuleFiles.concat([/node_modules/]), 66 | use: [ 67 | MiniCssExtractPlugin.loader, 68 | { 69 | loader: 'css-loader', 70 | options: { 71 | modules: { 72 | localIdentName, 73 | }, 74 | }, 75 | }, 76 | sassLoader, 77 | ], 78 | }, 79 | ], 80 | }, 81 | plugins: [ 82 | new CleanWebpackPlugin(), 83 | new MiniCssExtractPlugin({ 84 | filename: '[name].css', 85 | }), 86 | ], 87 | }); 88 | 89 | const server = Object.assign(serverBaseConfig, devBase, { 90 | output: { 91 | path: path.join(__dirname, '../local/server'), 92 | filename: 'index.js', 93 | }, 94 | module: serverModule, 95 | plugins: [ 96 | new CopyPlugin({ 97 | patterns: [{ from: `${staticAssetsPath}/error.html` }], 98 | }), 99 | new ServerStartPlugin('./local/server'), 100 | ], 101 | }); 102 | 103 | module.exports = [client, server]; 104 | -------------------------------------------------------------------------------- /src/client/epics/user.js: -------------------------------------------------------------------------------- 1 | import get from 'lodash.get'; 2 | 3 | import { ajax } from 'rxjs/ajax'; 4 | import { iif, of, map, switchMap, catchError, merge, throwError } from 'rxjs'; 5 | import { ofType } from 'redux-observable'; 6 | 7 | import requester from '../misc/requester'; 8 | import likeManager from '../misc/likeManager'; 9 | 10 | const query = `query { 11 | user { 12 | name 13 | img 14 | isAdmin 15 | likes 16 | owns 17 | } 18 | }`; 19 | 20 | const userAuthEpic$ = (action$) => 21 | action$.pipe( 22 | ofType('user/auth'), 23 | switchMap(() => 24 | requester({ 25 | query, 26 | }).pipe( 27 | switchMap((action2) => { 28 | if (get(action2, 'response.errors')) { 29 | return throwError(); 30 | } 31 | return iif( 32 | () => get(action2, 'response.data.user', false), 33 | of( 34 | { 35 | type: 'user/auth/success', 36 | payload: get(action2, 'response.data.user'), 37 | }, 38 | { 39 | type: 'color/set/likes', 40 | payload: get(action2, 'response.data.user.likes', []), 41 | }, 42 | { 43 | type: 'color/set/owns', 44 | payload: get(action2, 'response.data.user.owns', []), 45 | }, 46 | { 47 | type: 'modal', 48 | payload: [ 49 | 'success', 50 | `Welcome back, ${get(action2, 'response.data.user.name')}`, 51 | ], 52 | } 53 | ), 54 | of( 55 | { 56 | type: 'user/auth/fail', 57 | }, 58 | { 59 | type: 'color/set/likes', 60 | payload: likeManager.initLikes || [], 61 | }, 62 | { 63 | type: 'color/set/owns', 64 | payload: [], 65 | } 66 | ) 67 | ); 68 | }), 69 | catchError(() => 70 | of( 71 | { 72 | type: 'user/auth/fail', 73 | }, 74 | { 75 | type: 'user/logoff', 76 | }, 77 | { 78 | type: 'modal', 79 | payload: ['danger', 'Log in failed'], 80 | } 81 | ) 82 | ) 83 | ) 84 | ) 85 | ); 86 | 87 | const userLogOffEpic$ = (action$) => 88 | action$.pipe( 89 | ofType('user/logoff'), 90 | switchMap(() => 91 | merge( 92 | of({ 93 | type: 'color/set/likes', 94 | payload: likeManager.initLikes || [], 95 | }), 96 | of({ 97 | type: 'modal', 98 | payload: ['info', 'Logout successfully.'], 99 | }), 100 | ajax.getJSON('/auth/logout').pipe( 101 | map((res) => ({ 102 | type: 'user/logoff/success', 103 | payload: res, 104 | })) 105 | ) 106 | ) 107 | ) 108 | ); 109 | 110 | export default [userAuthEpic$, userLogOffEpic$]; 111 | -------------------------------------------------------------------------------- /src/components/Header/components/Header/index.spec.jsx: -------------------------------------------------------------------------------- 1 | import { render, fireEvent } from 'test-utils'; 2 | import Header from '.'; 3 | import { imgCdnUrl } from '../../../../constant'; 4 | 5 | describe('render properly', () => { 6 | beforeAll(() => { 7 | global.scrollTo = jest.fn(); 8 | }); 9 | test('render Header with anonymouse', () => { 10 | const likeNum = 2; 11 | const { getByText, getByTitle, getByLabelText, rerender } = render( 12 |
    22 | ); 23 | rerender( 24 |
    34 | ); 35 | fireEvent.click(getByLabelText('nav menu')); 36 | fireEvent.click(getByTitle('click to rotate')); 37 | fireEvent.click(getByText(`Saved (${likeNum})`)); 38 | expect(getByText('Login')).toBeTruthy(); 39 | }); 40 | 41 | test('render Header with login Status', () => { 42 | const onLogout = jest.fn(); 43 | const userInfo = { 44 | name: 'tom', 45 | img: 'http://www.image.com', 46 | isAdmin: true, 47 | likes: ['528', '529'], 48 | owns: ['12', '13'], 49 | }; 50 | const { getByText, rerender } = render( 51 |
    62 | ); 63 | rerender( 64 |
    73 | ); 74 | fireEvent.click(getByText('Popular')); 75 | fireEvent.click(getByText('Profile')); 76 | fireEvent.click(getByText('Saved')); 77 | fireEvent.click(getByText('Log Out')); 78 | expect(onLogout).toBeCalled(); 79 | }); 80 | 81 | test('render Header with login with no image url', () => { 82 | const onLogout = jest.fn(); 83 | const userInfo = { 84 | name: 'tom', 85 | img: null, 86 | likes: ['528', '529'], 87 | owns: ['12', '13'], 88 | }; 89 | const { getByRole } = render( 90 |
    101 | ); 102 | expect(getByRole('img').src).toBe(`http:${imgCdnUrl}/icon.png`); 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /src/client/modules/newcolor/components/NewColor/index.jsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { ChromePicker } from 'react-color'; 4 | import { useNavigate, useLocation } from 'react-router-dom'; 5 | import EditCanvas from '../EditCanvas'; 6 | import * as style from './style.sass'; 7 | import useTranslationContext from '../../../../../hooks/useTranslationContext'; 8 | import { isValidColorStr } from '../../../../../util'; 9 | 10 | const DEFAULTVALUE = '#81EEFF'; 11 | 12 | const NewColor = ({ onAdd, onColorInvalid }) => { 13 | const [language] = useTranslationContext(); 14 | const navigate = useNavigate(); 15 | const { search } = useLocation(); 16 | const searchLower = search.toLowerCase(); 17 | const defaultColorsPotential = searchLower.match(/[a-f0-9]{24}/); 18 | const defaultColors = defaultColorsPotential && defaultColorsPotential[0]; 19 | 20 | const [editColor, setEditColor] = useState(DEFAULTVALUE); 21 | const [activeIndex, setActiveIndex] = useState(0); 22 | const [colorValue, setColorValue] = useState( 23 | defaultColors 24 | ? [ 25 | `#${defaultColors.substring(0, 6)}`, 26 | `#${defaultColors.substring(6, 12)}`, 27 | `#${defaultColors.substring(12, 18)}`, 28 | `#${defaultColors.substring(18, 24)}`, 29 | ] 30 | : [null, null, null, null] 31 | ); 32 | 33 | const onSubmit = () => { 34 | const colorStr = colorValue.join(''); 35 | const good = isValidColorStr(colorStr); 36 | if (good) { 37 | onAdd(colorStr.substr(1)); 38 | resetColor(); 39 | } else { 40 | onColorInvalid(); 41 | } 42 | }; 43 | 44 | const onPickColor = ({ hex }) => { 45 | const newColorValue = [...colorValue]; 46 | newColorValue[activeIndex] = hex; 47 | setColorValue(newColorValue); 48 | setEditColor(hex); 49 | }; 50 | 51 | const onClickRow = (activeIndex) => { 52 | const editColor = colorValue[activeIndex] || DEFAULTVALUE; 53 | setEditColor(editColor); 54 | setActiveIndex(activeIndex); 55 | }; 56 | 57 | const resetColor = () => { 58 | setColorValue([null, null, null, null]); 59 | }; 60 | 61 | return ( 62 |
    63 |
    64 |
    65 | 66 |
    67 |
    68 | 73 |
    74 |
    75 |
    76 | 79 | 82 | 90 |
    91 |
    92 | ); 93 | }; 94 | 95 | NewColor.propTypes = { 96 | onAdd: PropTypes.func.isRequired, 97 | onColorInvalid: PropTypes.func.isRequired, 98 | }; 99 | 100 | export default NewColor; 101 | -------------------------------------------------------------------------------- /src/components/Html.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import OpenGraph from './OpenGraph'; 3 | import serialize from 'serialize-javascript'; 4 | import { 5 | tempDomId, 6 | reduxName, 7 | langSelectionKey, 8 | canvasOrientationKey, 9 | } from '../constant'; 10 | 11 | const Html = ({ 12 | title, 13 | style, 14 | script, 15 | children, 16 | csrfToken, 17 | isVertical, 18 | languageCode, 19 | lastBuildDate, 20 | initState, 21 | }) => ( 22 | 23 | 24 | 25 | 26 | 27 | {title} 28 | 32 | 36 | 37 | 41 | 45 | 46 | 29 | 159 | 160 | 161 |
    162 |
    163 |
    164 |
    165 |
    166 |
    167 | 168 |

    Something went wrong   :-(

    169 |

    Back to ColorBro Home

    170 |
    171 |
    172 | 173 | 174 | -------------------------------------------------------------------------------- /assets/static/error.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Error | ColorBro 6 | 10 | 15 | 29 | 159 | 160 | 161 |
    162 |
    163 |
    164 |
    165 |
    166 |
    167 | 168 |

    Something went wrong   :-(

    169 |

    Back to ColorBro Home

    170 |
    171 |
    172 | 173 | 174 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vp", 3 | "version": "1.30.3", 4 | "description": "source code of colorbro.com", 5 | "engines": { 6 | "node": ">= 16.x" 7 | }, 8 | "scripts": { 9 | "dev": "webpack --config ./webpack/develop.js", 10 | "lint": "eslint ./webpack ./src", 11 | "test": "jest --coverage", 12 | "t": "yarn lint && yarn test", 13 | "build": "webpack --config ./webpack/production.js", 14 | "start": "node dist/server" 15 | }, 16 | "repository": "git+https://github.com/im6/vp.git", 17 | "author": "im6", 18 | "license": "ISC", 19 | "bugs": { 20 | "url": "https://github.com/im6/vp/issues" 21 | }, 22 | "homepage": "https://github.com/im6/vp#readme", 23 | "devDependencies": { 24 | "@babel/core": "^7.27.3", 25 | "@babel/preset-env": "7.27.2", 26 | "@babel/preset-react": "^7.27.1", 27 | "@testing-library/react": "^16.3.0", 28 | "autoprefixer": "^10.4.21", 29 | "babel-loader": "^10.0.0", 30 | "bulma": "0.9.4", 31 | "clean-webpack-plugin": "^4.0.0", 32 | "compression-webpack-plugin": "^11.1.0", 33 | "copy-webpack-plugin": "^13.0.0", 34 | "css-loader": "^7.1.2", 35 | "css-minimizer-webpack-plugin": "^7.0.2", 36 | "dotenv": "^16.5.0", 37 | "eslint": "^9.27.0", 38 | "eslint-config-airbnb": "^19.0.4", 39 | "eslint-config-prettier": "^10.1.5", 40 | "eslint-plugin-import": "^2.27.5", 41 | "eslint-plugin-jest": "^28.11.1", 42 | "eslint-plugin-jsx-a11y": "^6.7.1", 43 | "eslint-plugin-prettier": "^5.4.0", 44 | "eslint-plugin-react": "7.37.5", 45 | "eslint-plugin-react-hooks": "^5.2.0", 46 | "jest": "^29.5.0", 47 | "jest-environment-jsdom": "^30.0.0-beta.3", 48 | "js-cookie": "^3.0.5", 49 | "mini-css-extract-plugin": "^2.7.6", 50 | "node-sass": "^9.0.0", 51 | "postcss": "^8.5.3", 52 | "postcss-loader": "^8.1.1", 53 | "prettier": "^3.5.3", 54 | "react-color": "^2.19.3", 55 | "redux-logger": "^3.0.6", 56 | "redux-observable": "^3.0.0-rc.2", 57 | "rxjs": "^7.8.1", 58 | "sass-loader": "^16.0.5", 59 | "webpack": "^5.99.9", 60 | "webpack-cli": "^6.0.1", 61 | "webpack-node-externals": "^3.0.0" 62 | }, 63 | "dependencies": { 64 | "@reduxjs/toolkit": "^2.8.2", 65 | "@testing-library/dom": "^10.4.0", 66 | "axios": "^1.9.0", 67 | "body-parser": "^2.2.0", 68 | "connect-redis": "^8.1.0", 69 | "cookie-parser": "^1.4.6", 70 | "csurf": "^1.11.0", 71 | "express": "^5.1.0", 72 | "express-graphql": "^0.12.0", 73 | "express-session": "^1.18.0", 74 | "graphql": "^16.11.0", 75 | "helmet": "^8.1.0", 76 | "lodash.get": "^4.4.2", 77 | "mongodb": "^6.16.0", 78 | "prop-types": "^15.8.1", 79 | "react": "^19.1.0", 80 | "react-dom": "^19.1.0", 81 | "react-redux": "^9.1.2", 82 | "react-router-dom": "6.30.1", 83 | "redis": "^5.1.1", 84 | "serialize-javascript": "^6.0.1", 85 | "style-loader": "^4.0.0", 86 | "uuid": "^11.1.0" 87 | }, 88 | "prettier": { 89 | "endOfLine": "auto", 90 | "trailingComma": "es5", 91 | "tabWidth": 2, 92 | "semi": true, 93 | "singleQuote": true 94 | }, 95 | "babel": { 96 | "presets": [ 97 | "@babel/preset-env", 98 | [ 99 | "@babel/preset-react", 100 | { 101 | "runtime": "automatic" 102 | } 103 | ] 104 | ] 105 | }, 106 | "eslintConfig": { 107 | "extends": [ 108 | "airbnb", 109 | "prettier", 110 | "plugin:react-hooks/recommended" 111 | ], 112 | "plugins": [ 113 | "react", 114 | "jsx-a11y", 115 | "import", 116 | "prettier", 117 | "jest" 118 | ], 119 | "rules": { 120 | "prettier/prettier": 2, 121 | "import/no-extraneous-dependencies": 0, 122 | "react-hooks/rules-of-hooks": "error", 123 | "react-hooks/exhaustive-deps": "warn", 124 | "import/no-unresolved": [ 125 | 2, 126 | { 127 | "ignore": [ 128 | "^components/" 129 | ] 130 | } 131 | ], 132 | "no-param-reassign": [ 133 | "error", 134 | { 135 | "props": true, 136 | "ignorePropertyModificationsFor": [ 137 | "acc", 138 | "req", 139 | "res", 140 | "draft" 141 | ] 142 | } 143 | ] 144 | }, 145 | "env": { 146 | "browser": true, 147 | "jest/globals": true 148 | } 149 | }, 150 | "jest": { 151 | "verbose": false, 152 | "testEnvironment": "jsdom", 153 | "clearMocks": true, 154 | "moduleDirectories": [ 155 | "node_modules", 156 | "/src/testing" 157 | ], 158 | "moduleNameMapper": { 159 | "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/src/testing/fileMock.js", 160 | "\\.(css|sass)$": "/src/testing/styleMock.js", 161 | "^components/(.*)": "/src/components/$1" 162 | }, 163 | "coverageDirectory": "coverage", 164 | "coverageThreshold": { 165 | "global": { 166 | "branches": 100, 167 | "functions": 100, 168 | "lines": 100, 169 | "statements": -10 170 | } 171 | } 172 | }, 173 | "browserslist": [ 174 | "defaults", 175 | "cover 99.5%" 176 | ] 177 | } 178 | -------------------------------------------------------------------------------- /src/client/epics/color.js: -------------------------------------------------------------------------------- 1 | import get from 'lodash.get'; 2 | import { 3 | of, 4 | iif, 5 | map, 6 | tap, 7 | filter, 8 | switchMap, 9 | catchError, 10 | throttleTime, 11 | } from 'rxjs'; 12 | import { ofType } from 'redux-observable'; 13 | 14 | import requester from '../misc/requester'; 15 | import likeManager from '../misc/likeManager'; 16 | import { download, copyText } from '../misc/util'; 17 | 18 | const colorql = `query($cate: ColorCategory!) { 19 | color(category: $cate) { 20 | id 21 | star 22 | color 23 | userId 24 | username 25 | createdDate 26 | } 27 | }`; 28 | 29 | const likeql = `mutation($val: LikeColorInputType!) { 30 | likeColor(input: $val) { 31 | status 32 | } 33 | } 34 | `; 35 | 36 | const createql = `mutation($val: CreateColorInputType!) { 37 | createColor(input: $val) { 38 | data 39 | status 40 | } 41 | } 42 | `; 43 | 44 | const getColorEpic$ = (action$) => 45 | action$.pipe( 46 | ofType('color/get'), 47 | switchMap(() => 48 | requester({ 49 | query: colorql, 50 | variables: { cate: 'PUBLIC' }, 51 | }).pipe( 52 | map((res) => { 53 | const colors = get(res, 'response.data.color', null); 54 | return colors 55 | ? { 56 | type: 'color/get/success', 57 | payload: colors, 58 | } 59 | : { type: 'color/get/fail' }; 60 | }), 61 | catchError(() => 62 | of( 63 | { type: 'color/get/fail' }, 64 | { 65 | type: 'modal', 66 | payload: ['danger', 'Get color data error.'], 67 | } 68 | ) 69 | ) 70 | ) 71 | ) 72 | ); 73 | 74 | const toggleLikeEpic$ = (action$) => 75 | action$.pipe( 76 | ofType('color/toggleLike'), 77 | throttleTime(2000), 78 | tap((action0) => { 79 | const { willLike, id, isAuth } = action0.payload; 80 | if (!isAuth) { 81 | if (willLike) { 82 | likeManager.addLike(id); 83 | } else { 84 | likeManager.removeLike(id); 85 | } 86 | } 87 | }), 88 | switchMap((action0) => { 89 | const { willLike, id } = action0.payload; 90 | return requester({ 91 | query: likeql, 92 | variables: { 93 | val: { 94 | id, 95 | willLike, 96 | }, 97 | }, 98 | }).pipe( 99 | filter( 100 | (ajaxRes) => get(ajaxRes, 'response.data.likeColor.status', 1) !== 0 101 | ), 102 | map((ajaxRes) => get(ajaxRes, 'response.errors[0].message', true)), 103 | catchError((err) => of(get(err, 'response.errors[0].message', true))), 104 | tap((err) => { 105 | console.error('toggle like error: ', err); // eslint-disable-line no-console 106 | }) 107 | ); 108 | }) 109 | ); 110 | const colorDownloadEpic$ = (action$) => 111 | action$.pipe( 112 | ofType('color/download'), 113 | throttleTime(5000), 114 | tap(({ payload }) => { 115 | download(`colorbro_${payload.id}.png`, payload.color); 116 | }), 117 | map(() => ({ 118 | type: 'modal', 119 | payload: ['link', 'Downloading ...'], 120 | })) 121 | ); 122 | 123 | const addNewEpic$ = (action$) => 124 | action$.pipe( 125 | ofType('color/addNew'), 126 | throttleTime(5000), 127 | switchMap(({ payload }) => 128 | requester({ 129 | query: createql, 130 | variables: { 131 | val: payload, 132 | }, 133 | }).pipe( 134 | switchMap((res) => { 135 | const isGood = 136 | !get(res, 'response.errors') && 137 | get(res, 'response.data.createColor.status', 1) === 0; 138 | const id = get(res, 'response.data.createColor.data', null); 139 | const { color } = payload; 140 | const successPayload = id && { 141 | id: id.toString(), 142 | color, 143 | name: '', 144 | star: 0, 145 | }; 146 | return iif( 147 | () => isGood, 148 | of( 149 | { 150 | type: 'color/addNew/success', 151 | payload: successPayload, 152 | }, 153 | { 154 | type: 'modal', 155 | payload: ['success', 'Create color successfully, thanks.'], 156 | } 157 | ), 158 | of( 159 | { 160 | type: 'color/addNew/fail', 161 | payload: get(res, 'response.errors[0].message'), 162 | }, 163 | { 164 | type: 'modal', 165 | payload: ['danger', 'Create color failed.'], 166 | } 167 | ) 168 | ); 169 | }) 170 | ) 171 | ) 172 | ); 173 | 174 | const copyColorEpic$ = (action$) => 175 | action$.pipe( 176 | ofType('color/copy'), 177 | tap(({ payload }) => { 178 | copyText(payload.substring(1)); 179 | }), 180 | map(() => ({ 181 | type: 'modal', 182 | payload: ['success', 'Copy to clipboard successfully'], 183 | })) 184 | ); 185 | 186 | export default [ 187 | getColorEpic$, 188 | toggleLikeEpic$, 189 | colorDownloadEpic$, 190 | addNewEpic$, 191 | copyColorEpic$, 192 | ]; 193 | -------------------------------------------------------------------------------- /src/server/middlewares/graphql/root.js: -------------------------------------------------------------------------------- 1 | import get from 'lodash.get'; 2 | import { GraphQLError } from 'graphql'; 3 | 4 | import { 5 | fetchFacebookProfile, 6 | fetchWeiboProfile, 7 | fetchGithubProfile, 8 | } from '../../resource/oauth'; 9 | import { isAuth, isAdmin, getTokenInfo } from '../../helper'; 10 | import { isValidColorStr } from '../../../util'; 11 | import { 12 | getColorList, 13 | createColorDocument, 14 | flipColorVisibility, 15 | deleteColor, 16 | incrementColorStar, 17 | getColorsByUser, 18 | checkUser, 19 | createUser, 20 | updateUserLoginDate, 21 | getUserSaveColorList, 22 | upsertUserSaveColor, 23 | deleteUserSaveColor, 24 | deleteColorFromUserSave, 25 | } from '../../resource/mongodb/crud'; 26 | 27 | const root = { 28 | async user(_, req) { 29 | const tokenInfo = getTokenInfo(req); 30 | if (isAuth(req, true) && tokenInfo) { 31 | const oauthType = req.session.app.oauth; 32 | try { 33 | let oauthData = null; 34 | if (oauthType === 'fb') { 35 | oauthData = await fetchFacebookProfile(tokenInfo); 36 | } else if (oauthType === 'wb') { 37 | oauthData = await fetchWeiboProfile(tokenInfo); 38 | } else if (oauthType === 'gh') { 39 | oauthData = await fetchGithubProfile(tokenInfo); 40 | } 41 | 42 | const { name, oauthId } = oauthData; 43 | const optionalUser = await checkUser(oauthType, oauthId); 44 | 45 | if (optionalUser) { 46 | // existing user. 47 | const { isAdmin: hasAdminFlag, userId } = optionalUser; 48 | 49 | req.session.app.dbInfo = { 50 | id: userId, 51 | name, // grab name from oauth 52 | isAdmin: hasAdminFlag, 53 | }; 54 | 55 | const ownDataArr = await getColorsByUser(userId); 56 | const saveDataArr = await getUserSaveColorList(userId); 57 | 58 | await updateUserLoginDate(userId); 59 | return { 60 | name, 61 | isAdmin: hasAdminFlag, 62 | img: oauthData.img || null, 63 | likes: saveDataArr, 64 | owns: ownDataArr, 65 | }; 66 | } 67 | 68 | // user first time login, save it. 69 | const newUserId = await createUser({ 70 | oAuthId: oauthId, 71 | oAuthType: oauthType, 72 | name, 73 | isAdmin: false, 74 | lastLogin: new Date(), 75 | }); 76 | 77 | req.session.app.dbInfo = { 78 | id: newUserId, 79 | name, 80 | isAdmin: false, 81 | }; 82 | return { 83 | name, 84 | isAdmin: false, 85 | img: get(oauthData, 'picture.data.url', null), 86 | likes: [], 87 | owns: [], 88 | }; 89 | } catch (err) { 90 | return new GraphQLError(err.toString()); 91 | } 92 | } else { 93 | return null; 94 | } 95 | }, 96 | 97 | async color({ category }, req) { 98 | if (!isAdmin(req) && category === 'ANONYMOUS') { 99 | return new GraphQLError('color error: no admin access'); 100 | } 101 | 102 | try { 103 | const colors = await getColorList(category); 104 | return colors; 105 | } catch (err) { 106 | return new GraphQLError(err.toString()); 107 | } 108 | }, 109 | 110 | async likeColor({ input }, req) { 111 | const { id, willLike } = input; 112 | try { 113 | if (isAuth(req)) { 114 | const userId = get(req, 'session.app.dbInfo.id', null); 115 | if (willLike) { 116 | await upsertUserSaveColor(userId, id); 117 | } else { 118 | await deleteUserSaveColor(userId, id); 119 | } 120 | } 121 | 122 | if (willLike) { 123 | const status = await incrementColorStar(id); 124 | return { 125 | status, 126 | }; 127 | } 128 | return { 129 | status: 0, 130 | }; 131 | } catch (err) { 132 | return new GraphQLError(err.toString()); 133 | } 134 | }, 135 | 136 | async createColor({ input }, req) { 137 | const { color } = input; 138 | if (!isValidColorStr(`#${color}`)) { 139 | return new GraphQLError('create error: invalid color input'); 140 | } 141 | 142 | const sessionUserid = get(req, 'session.app.dbInfo.id', null); 143 | const userId = isAuth(req) ? sessionUserid : null; 144 | 145 | try { 146 | const newId = await createColorDocument( 147 | { 148 | star: (Math.random() * 20).toFixed(), 149 | color, 150 | createdBy: undefined, // define later 151 | hidden: !userId, 152 | createdDate: new Date(), 153 | }, 154 | userId 155 | ); 156 | return { 157 | status: 0, 158 | data: newId, 159 | }; 160 | } catch (err) { 161 | return new GraphQLError(err.toString()); 162 | } 163 | }, 164 | 165 | async adjudicateColor({ input }, req) { 166 | if (!isAdmin(req)) { 167 | return new GraphQLError('adjudicate error: no admin access'); 168 | } 169 | const { id, willLike } = input; 170 | try { 171 | if (willLike) { 172 | const status = await flipColorVisibility(id); 173 | return { 174 | status, 175 | }; 176 | } 177 | // will unlike operation 178 | const status = await deleteColor(id); 179 | await deleteColorFromUserSave(id); 180 | return { 181 | status, 182 | }; 183 | } catch (err) { 184 | return new GraphQLError(err.toString()); 185 | } 186 | }, 187 | }; 188 | 189 | export default root; 190 | -------------------------------------------------------------------------------- /src/components/Header/components/Header/index.jsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Link, useLocation } from 'react-router-dom'; 4 | import * as style from './style.sass'; 5 | import TranslationIcon from '../TranslationIcon'; 6 | import LanguageDropdown from '../LanguageDropdown'; 7 | import ToggleButton from '../ToggleButton'; 8 | import { imgCdnUrl } from '../../../../constant'; 9 | import useLayoutContext from '../../../../hooks/useLayoutContext'; 10 | import useTranslationContext from '../../../../hooks/useTranslationContext'; 11 | 12 | const { selected } = style; 13 | 14 | const Header = ({ 15 | detail, 16 | likeNum, 17 | weiboUrl, 18 | githubUrl, 19 | facebookUrl, 20 | languages, 21 | onLogout, 22 | }) => { 23 | const location = useLocation(); 24 | const url = location.pathname; 25 | const [isMenuOpen, toggleMenu] = useState(false); 26 | const [isVertical, setVertical] = useLayoutContext(); 27 | const [language, setLanguage] = useTranslationContext(); 28 | 29 | const isAuth = Boolean(detail); 30 | const isAdmin = isAuth && detail.isAdmin; 31 | const userImgUrl = isAuth && detail.img; 32 | const imagUrl = userImgUrl || `${imgCdnUrl}/icon.png`; 33 | 34 | const selectPopular = url === '/popular'; 35 | const selectLatest = ['/', '/latest'].includes(url); 36 | const selectProfile = ['/like', '/portfolio'].includes(url); 37 | const selectSaved = url === '/like'; 38 | const selectCreate = url === '/new'; 39 | 40 | const onCloseNav = () => { 41 | toggleMenu(false); 42 | }; 43 | const onClickToScroll = () => { 44 | toggleMenu(false); 45 | window.scrollTo(0, 0); 46 | }; 47 | const onClickRotate = () => { 48 | toggleMenu(false); 49 | setVertical(!isVertical); 50 | }; 51 | 52 | return ( 53 | 207 | ); 208 | }; 209 | 210 | Header.propTypes = { 211 | detail: PropTypes.object, 212 | weiboUrl: PropTypes.string, 213 | githubUrl: PropTypes.string, 214 | facebookUrl: PropTypes.string, 215 | likeNum: PropTypes.number, 216 | languages: PropTypes.array.isRequired, 217 | onLogout: PropTypes.func.isRequired, 218 | }; 219 | 220 | export default Header; 221 | --------------------------------------------------------------------------------