├── .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 |
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 |
11 |
12 |
13 |
14 |
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 |
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 |
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 |
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 |
16 | {color}
17 |
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 |
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 |
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 
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 |
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 |
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 |
--------------------------------------------------------------------------------