├── .babelrc
├── .eslintignore
├── .eslintrc.json
├── .gitignore
├── .prettierignore
├── .prettierrc
├── .stylelintrc
├── Procfile
├── README.md
├── index.js
├── jest.config.js
├── jsconfig.json
├── nodemon.json
├── package.json
├── public
├── favicon.ico
├── manifest.json
├── robots.txt
└── static
│ └── images
│ ├── svg
│ ├── menu-icon.svg
│ └── oct-icon.svg
│ └── touch
│ ├── launcher-icon-2x.png
│ ├── launcher-icon-4x.png
│ └── launcher-icon.png
├── src
├── actions
│ ├── ui.js
│ └── user.js
├── client.js
├── components
│ ├── App
│ │ └── index.js
│ ├── Container
│ │ └── index.js
│ ├── Error
│ │ ├── index.js
│ │ ├── index.spec.js
│ │ └── index.stories.js
│ ├── Footer
│ │ ├── index.js
│ │ ├── index.spec.js
│ │ └── index.stories.js
│ ├── Header
│ │ ├── index.js
│ │ ├── index.spec.js
│ │ └── index.stories.js
│ ├── Logo
│ │ ├── index.js
│ │ └── index.stories.js
│ ├── Main
│ │ └── index.js
│ ├── Menu
│ │ ├── index.js
│ │ ├── index.spec.js
│ │ └── index.stories.js
│ ├── MenuIcon
│ │ ├── index.js
│ │ ├── index.spec.js
│ │ └── index.stories.js
│ ├── OctIcon
│ │ ├── index.js
│ │ ├── index.spec.js
│ │ └── index.stories.js
│ ├── PreloadLink
│ │ └── index.js
│ ├── StackList
│ │ ├── index.js
│ │ ├── index.spec.js
│ │ └── index.stories.js
│ ├── SubTitle
│ │ ├── index.js
│ │ ├── index.spec.js
│ │ └── index.stories.js
│ ├── Title
│ │ ├── index.js
│ │ ├── index.spec.js
│ │ └── index.stories.js
│ ├── UserDetail
│ │ ├── data.js
│ │ ├── index.js
│ │ ├── index.spec.js
│ │ └── index.stories.js
│ ├── UserList
│ │ ├── data.js
│ │ ├── index.js
│ │ └── index.stories.js
│ └── withExtendRouter
│ │ └── index.js
├── config
│ ├── env.js
│ └── url.js
├── pages
│ ├── about
│ │ └── index.js
│ ├── home
│ │ └── index.js
│ ├── notFound
│ │ └── index.js
│ ├── redirectAbout
│ │ └── index.js
│ └── userDetail
│ │ └── index.js
├── reducers
│ ├── index.js
│ ├── ui.js
│ └── user.js
├── routes.js
├── server.js
├── styles
│ ├── index.js
│ ├── media.js
│ └── variables.js
├── sw.js
└── utils
│ ├── configureStore.js
│ ├── customPush.js
│ ├── getHtmlString.js
│ ├── getPreloadResorceElement.js
│ ├── helpers.js
│ ├── link.js
│ ├── meta.js
│ └── path.js
└── tools
├── jest
└── setup.js
├── storybook
├── backgroundDecorator.js
├── config.js
└── routerDecorator.js
└── webpack
├── getClientPlugins.js
├── getModule.js
├── getResolve.js
├── getServerPlugins.js
├── webpack.client.babel.js
└── webpack.server.babel.js
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "development": {
4 | "plugins": [
5 | "react-hot-loader/babel",
6 | [
7 | "babel-plugin-styled-components",
8 | {
9 | "ssr": true
10 | }
11 | ]
12 | ]
13 | }
14 | },
15 | "plugins": [
16 | [
17 | "module-resolver",
18 | {
19 | "root": [
20 | "./",
21 | "./src"
22 | ]
23 | }
24 | ],
25 | "add-module-exports",
26 | "@loadable/babel-plugin",
27 | [
28 | "@babel/plugin-transform-runtime",
29 | {
30 | "helpers": false
31 | }
32 | ]
33 | ],
34 | "presets": [
35 | [
36 | "@babel/preset-env",
37 | {
38 | "targets": {
39 | "browsers": "last 2 versions",
40 | "node": "current"
41 | }
42 | }
43 | ],
44 | "@babel/preset-react"
45 | ]
46 | }
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | dist/*
2 | coverage/*
3 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [
3 | "airbnb",
4 | "eslint:recommended",
5 | "plugin:react/recommended",
6 | "plugin:prettier/recommended",
7 | "plugin:jest/recommended",
8 | "prettier/react"
9 | ],
10 | "rules": {
11 | "global-require": 0,
12 | "class-methods-use-this": 0,
13 | "import/no-extraneous-dependencies": 0,
14 | "import/prefer-default-export": 0,
15 | "react/no-danger": 0,
16 | "react/jsx-filename-extension": 0,
17 | "react/jsx-props-no-spreading": 0,
18 | "react/no-array-index-key": 0,
19 | "react/require-default-props": 0,
20 | "react/prop-types": 0
21 | },
22 | "parser": "babel-eslint",
23 | "env": {
24 | "browser": true,
25 | "node": true,
26 | "es2020": true
27 | },
28 | "settings": {
29 | "import/resolver": {
30 | "babel-module": {
31 | "extensions": [".js"]
32 | }
33 | }
34 | },
35 | "globals": {
36 | "shallow": true,
37 | "render": true,
38 | "mount": true,
39 | "toJson": true,
40 | "importScripts": true
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/*
2 | dist/*
3 | public/static/javascripts/loadable-stats.json
4 | coverage/*
5 | *.log
6 | yarn.lock
7 | package-lock.json
8 | .DS_Store
9 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | package.json
2 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true,
3 | "trailingComma": "es5"
4 | }
5 |
--------------------------------------------------------------------------------
/.stylelintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [
3 | "stylelint-config-standard",
4 | "stylelint-config-styled-components",
5 | "stylelint-config-prettier"
6 | ],
7 | "syntax": "css-in-js"
8 | }
9 |
--------------------------------------------------------------------------------
/Procfile:
--------------------------------------------------------------------------------
1 | web: node index.js
2 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # react-ssr-starter
2 |
3 | All have been introduced React environment.
4 |
5 | ## Features
6 |
7 | - [react](https://reactjs.org/)
8 | - [react-router](https://reacttraining.com/react-router/)
9 | - [react-helmet-async](https://github.com/staylor/react-helmet-async/)
10 | - [react-hot-loader](http://gaearon.github.io/react-hot-loader/)
11 | - [redux](https://rackt.github.io/redux/)
12 | - [styled-components](https://www.styled-components.com/)
13 | - [loadable-components](https://www.smooth-code.com/open-source/loadable-components/)
14 | - [express](http://expressjs.com/)
15 | - [workbox](https://developers.google.com/web/tools/workbox/)
16 | - [eslint](https://eslint.org/)
17 | - [stylelint](https://stylelint.io/)
18 | - [prettier](https://prettier.io/)
19 | - [jest](https://facebook.github.io/jest/)
20 | - [enzyme](http://airbnb.io/enzyme/)
21 | - [storybook](https://storybook.js.org/)
22 | - [webpack](https://webpack.js.org/)
23 | - [babel](https://babeljs.io/)
24 |
25 | ## Install
26 |
27 | ```
28 | $ git clone https://github.com/osamu38/react-ssr-starter.git
29 | $ cd react-ssr-starter
30 | $ npm i
31 | ```
32 |
33 | ## Run
34 |
35 | ```
36 | $ npm run dev
37 | ```
38 |
39 | Go to `http://localhost:2525/`.
40 |
41 | ## Build
42 |
43 | ```
44 | $ npm run build
45 | $ npm run build:client (Only build client)
46 | $ npm run build:server (Only build server)
47 | ```
48 |
49 | ## Build and analyze
50 |
51 | ```
52 | $ npm run build:analyze
53 | $ npm run build:client:analyze
54 | $ npm run build:server:analyze
55 | ```
56 |
57 | ## Run for production
58 |
59 | ```
60 | npm start
61 | ```
62 |
63 | Go to `http://localhost:2525/`.
64 |
65 | ## Adding pages
66 |
67 | Basically page component is implemented using Functional Component.
68 |
69 | `pages/home/index.js`
70 |
71 | ```jsx
72 | import React from 'react';
73 | import { Helmet } from 'react-helmet-async';
74 | import Title from 'components/Title';
75 | import SubTitle from 'components/SubTitle';
76 | import UserList from 'components/UserList';
77 | import { fetchUsers } from 'actions/user';
78 |
79 | const HomePage = (props) => {
80 | const {
81 | state: {
82 | user: { userList },
83 | },
84 | } = props;
85 |
86 | return (
87 | <>
88 |
89 |
Home Page
90 | User List
91 |
92 | >
93 | );
94 | };
95 |
96 | HomePage.loadData = async (ctx) => {
97 | const {
98 | dispatch,
99 | state: {
100 | user: { userList },
101 | },
102 | } = ctx;
103 |
104 | if (!userList.length) {
105 | return dispatch(fetchUsers());
106 | }
107 | return Promise.resolve();
108 | };
109 |
110 | export default HomePage;
111 | ```
112 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | if (process.env.NODE_ENV === 'production') {
2 | require('./dist/server');
3 | } else {
4 | require('@babel/register');
5 | require('@babel/polyfill');
6 | require('./src/server');
7 | }
8 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | setupFiles: ['raf/polyfill', '/tools/jest/setup.js'],
3 | transform: {
4 | '.js': '/node_modules/babel-jest',
5 | },
6 | snapshotSerializers: ['enzyme-to-json/serializer'],
7 | };
8 |
--------------------------------------------------------------------------------
/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": "./",
4 | "paths": {
5 | "*": ["./*", "src/*"]
6 | }
7 | },
8 | "exclude": ["node_modules"]
9 | }
10 |
--------------------------------------------------------------------------------
/nodemon.json:
--------------------------------------------------------------------------------
1 | {
2 | "watch": ["src/server.js", "src/routes.js", "src/utils/getHtmlString.js"]
3 | }
4 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-ssr-starter",
3 | "version": "1.0.0",
4 | "description": "All have been introduced React environment",
5 | "bugs": {
6 | "url": "https://github.com/osamu38/react-ssr-starter/issues"
7 | },
8 | "license": "MIT",
9 | "author": "osamu38",
10 | "main": "index.js",
11 | "sideEffects": false,
12 | "repository": {
13 | "type": "git",
14 | "url": "git+https://github.com/osamu38/react-ssr-starter.git"
15 | },
16 | "scripts": {
17 | "build": "npm run build:client && npm run build:server",
18 | "build:analyze": "npm run build:client:analyze & npm run build:server:analyze",
19 | "build:client": "NODE_ENV=production webpack --progress --env --config ./tools/webpack/webpack.client.babel.js",
20 | "build:client:analyze": "NODE_ENV=production webpack --progress --env.analyze --config ./tools/webpack/webpack.client.babel.js",
21 | "build:server": "NODE_ENV=production webpack --progress --env --config ./tools/webpack/webpack.server.babel.js",
22 | "build:server:analyze": "NODE_ENV=production webpack --progress --env.analyze --config ./tools/webpack/webpack.server.babel.js",
23 | "lint": "eslint . --fix",
24 | "lint:css": "stylelint './src/**/*.js'",
25 | "dev": "nodemon index",
26 | "start": "NODE_ENV=production node index",
27 | "storybook": "start-storybook -s ./public -p 2626 -c ./tools/storybook",
28 | "test": "NODE_ENV=test jest",
29 | "test:update": "npm test -- -u",
30 | "test:coverage": "npm test -- --coverage",
31 | "heroku-postbuild": "npm run build"
32 | },
33 | "dependencies": {
34 | "@loadable/component": "^5.13.1",
35 | "@loadable/server": "^5.13.1",
36 | "axios": "^0.19.2",
37 | "body-parser": "^1.19.0",
38 | "compression": "^1.7.4",
39 | "cookie-parser": "^1.4.5",
40 | "express": "^5.0.0-alpha.7",
41 | "helmet": "^3.23.3",
42 | "hpp": "^0.2.3",
43 | "html-minifier": "^4.0.0",
44 | "morgan": "^1.10.0",
45 | "path-to-regexp": "^3.1.0",
46 | "preact": "^10.4.5",
47 | "query-string": "^6.13.1",
48 | "react": "^16.13.1",
49 | "react-dom": "^16.13.1",
50 | "react-helmet-async": "^1.0.6",
51 | "react-redux": "5.1.1",
52 | "react-router-config": "5.1.1",
53 | "react-router-dom": "^5.2.0",
54 | "redux": "^4.0.5",
55 | "redux-thunk": "^2.3.0",
56 | "serialize-javascript": "^4.0.0",
57 | "serve-favicon": "^2.5.0",
58 | "styled-components": "^4.4.1",
59 | "styled-reset": "^4.2.0"
60 | },
61 | "devDependencies": {
62 | "@babel/core": "^7.10.4",
63 | "@babel/plugin-transform-runtime": "^7.10.4",
64 | "@babel/polyfill": "^7.10.4",
65 | "@babel/preset-env": "^7.10.4",
66 | "@babel/preset-react": "^7.10.4",
67 | "@babel/register": "^7.10.4",
68 | "@loadable/babel-plugin": "^5.13.0",
69 | "@loadable/webpack-plugin": "^5.13.0",
70 | "@storybook/addon-links": "^5.3.19",
71 | "@storybook/react": "^5.3.19",
72 | "babel-eslint": "^10.1.0",
73 | "babel-jest": "^26.1.0",
74 | "babel-loader": "^8.1.0",
75 | "babel-plugin-add-module-exports": "^1.0.2",
76 | "babel-plugin-module-resolver": "^4.0.0",
77 | "babel-plugin-styled-components": "^1.10.7",
78 | "clean-webpack-plugin": "1.0.1",
79 | "copy-webpack-plugin": "^5.1.1",
80 | "enzyme": "^3.11.0",
81 | "enzyme-adapter-react-16": "^1.15.2",
82 | "enzyme-to-json": "^3.5.0",
83 | "eslint": "^7.4.0",
84 | "eslint-config-airbnb": "^18.2.0",
85 | "eslint-config-prettier": "^6.11.0",
86 | "eslint-import-resolver-babel-module": "^5.1.2",
87 | "eslint-plugin-import": "^2.22.0",
88 | "eslint-plugin-jest": "^23.18.0",
89 | "eslint-plugin-jsx-a11y": "^6.3.1",
90 | "eslint-plugin-prettier": "^3.1.4",
91 | "eslint-plugin-react": "^7.20.3",
92 | "eslint-plugin-react-hooks": "^4.0.6",
93 | "friendly-errors-webpack-plugin": "^1.7.0",
94 | "jest": "^26.1.0",
95 | "jest-styled-components": "^7.0.2",
96 | "json-loader": "^0.5.7",
97 | "nodemon": "^2.0.4",
98 | "prettier": "^2.0.5",
99 | "raf": "^3.4.1",
100 | "react-hot-loader": "^4.12.21",
101 | "redux-logger": "^3.0.6",
102 | "stylelint": "^13.6.1",
103 | "stylelint-config-prettier": "^8.0.2",
104 | "stylelint-config-standard": "^20.0.0",
105 | "stylelint-config-styled-components": "^0.1.1",
106 | "terser-webpack-plugin": "^3.0.6",
107 | "webpack": "^4.43.0",
108 | "webpack-bundle-analyzer": "^3.8.0",
109 | "webpack-cli": "^3.3.12",
110 | "webpack-dev-middleware": "^3.7.2",
111 | "webpack-hot-middleware": "^2.25.0",
112 | "webpack-manifest-plugin": "^2.2.0",
113 | "webpack-node-externals": "^1.7.2",
114 | "workbox-sw": "^5.1.3",
115 | "workbox-webpack-plugin": "^5.1.3"
116 | },
117 | "engines": {
118 | "node": "12.14.1",
119 | "npm": "6.13.4"
120 | }
121 | }
122 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/osamu38/react-ssr-starter/03ae982346abeb4c24e6858239fb6b94a80aa9e0/public/favicon.ico
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "lang": "ja",
3 | "display": "standalone",
4 | "short_name": "RSSRS",
5 | "name": "react-ssr-starter",
6 | "icons": [
7 | {
8 | "src": "/static/images/touch/launcher-icon.png",
9 | "type": "image/png",
10 | "sizes": "48x48"
11 | },
12 | {
13 | "src": "/static/images/touch/launcher-icon-2x.png",
14 | "type": "image/png",
15 | "sizes": "96x96"
16 | },
17 | {
18 | "src": "/static/images/touch/launcher-icon-4x.png",
19 | "type": "image/png",
20 | "sizes": "192x192"
21 | }
22 | ],
23 | "start_url": "/",
24 | "theme_color": "#f68084",
25 | "background_color": "#f68084"
26 | }
27 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | User-agent: *
2 |
--------------------------------------------------------------------------------
/public/static/images/svg/menu-icon.svg:
--------------------------------------------------------------------------------
1 |
20 |
--------------------------------------------------------------------------------
/public/static/images/svg/oct-icon.svg:
--------------------------------------------------------------------------------
1 |
13 |
--------------------------------------------------------------------------------
/public/static/images/touch/launcher-icon-2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/osamu38/react-ssr-starter/03ae982346abeb4c24e6858239fb6b94a80aa9e0/public/static/images/touch/launcher-icon-2x.png
--------------------------------------------------------------------------------
/public/static/images/touch/launcher-icon-4x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/osamu38/react-ssr-starter/03ae982346abeb4c24e6858239fb6b94a80aa9e0/public/static/images/touch/launcher-icon-4x.png
--------------------------------------------------------------------------------
/public/static/images/touch/launcher-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/osamu38/react-ssr-starter/03ae982346abeb4c24e6858239fb6b94a80aa9e0/public/static/images/touch/launcher-icon.png
--------------------------------------------------------------------------------
/src/actions/ui.js:
--------------------------------------------------------------------------------
1 | export const openMenu = () => {
2 | return (dispatch) => {
3 | dispatch({ type: 'OPEN_MENU' });
4 | };
5 | };
6 | export const closeMenu = () => {
7 | return (dispatch) => {
8 | dispatch({ type: 'CLOSE_MENU' });
9 | };
10 | };
11 | export const showError = (error) => {
12 | return (dispatch) => {
13 | dispatch({
14 | type: 'SHOW_ERROR',
15 | payload: {
16 | error,
17 | },
18 | });
19 | };
20 | };
21 | export const hideError = () => {
22 | return (dispatch) => {
23 | dispatch({ type: 'HIDE_ERROR' });
24 | };
25 | };
26 |
--------------------------------------------------------------------------------
/src/actions/user.js:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 |
3 | export const fetchUser = (id) => {
4 | return (dispatch) =>
5 | axios
6 | .get(`https://jsonplaceholder.typicode.com/users/${id}`)
7 | .then((res) => {
8 | dispatch({
9 | type: 'FETCH_USER',
10 | payload: res.data,
11 | });
12 | })
13 | .catch((err) => {
14 | // eslint-disable-next-line no-console
15 | console.error(err);
16 | });
17 | };
18 | export const fetchUsers = () => {
19 | return (dispatch) =>
20 | axios
21 | .get('https://jsonplaceholder.typicode.com/users')
22 | .then((res) => {
23 | dispatch({
24 | type: 'FETCH_USERS',
25 | payload: res.data,
26 | });
27 | })
28 | .catch((err) => {
29 | // eslint-disable-next-line no-console
30 | console.error(err);
31 | });
32 | };
33 |
--------------------------------------------------------------------------------
/src/client.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import { Provider } from 'react-redux';
4 | import { BrowserRouter } from 'react-router-dom';
5 | import { HelmetProvider } from 'react-helmet-async';
6 | import { loadableReady } from '@loadable/component';
7 | import configureStore from 'utils/configureStore';
8 | import App from 'components/App';
9 | import { isDevelopment } from 'config/env';
10 |
11 | // eslint-disable-next-line no-underscore-dangle
12 | const initialState = window.__INITIAL_STATE__;
13 | const store = configureStore(initialState);
14 | const root = document.getElementById('root');
15 |
16 | loadableReady().then(() => {
17 | if (root) {
18 | ReactDOM.hydrate(
19 |
20 |
21 |
22 |
23 |
24 |
25 | ,
26 | root
27 | );
28 | }
29 | });
30 |
31 | window.addEventListener('load', () => {
32 | if ('serviceWorker' in navigator && navigator.serviceWorker) {
33 | if (!isDevelopment) {
34 | navigator.serviceWorker
35 | .register('/static/javascripts/sw.js', { scope: '/' })
36 | .then(() => {
37 | // SW registered
38 | })
39 | .catch(() => {
40 | // SW registration failed
41 | });
42 | } else {
43 | navigator.serviceWorker.getRegistrations().then((registrations) => {
44 | registrations.forEach((registration) => {
45 | registration.unregister();
46 | });
47 | });
48 | caches.keys().then((keys) => {
49 | const promises = [];
50 |
51 | keys.forEach((cacheName) => {
52 | if (cacheName) {
53 | promises.push(caches.delete(cacheName));
54 | }
55 | });
56 | });
57 | }
58 | }
59 | });
60 |
--------------------------------------------------------------------------------
/src/components/App/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { bindActionCreators, compose } from 'redux';
3 | import { connect } from 'react-redux';
4 | import { Route, Switch } from 'react-router-dom';
5 | import { Helmet } from 'react-helmet-async';
6 | import Container from 'components/Container';
7 | import Footer from 'components/Footer';
8 | import Header from 'components/Header';
9 | import Main from 'components/Main';
10 | import Error from 'components/Error';
11 | import withExtendRouter from 'components/withExtendRouter';
12 | import * as userActions from 'actions/user';
13 | import * as uiActions from 'actions/ui';
14 | import meta from 'utils/meta';
15 | import link from 'utils/link';
16 | import routes from 'routes';
17 | import { isDevelopment } from 'config/env';
18 | import GlobalStyle from 'styles';
19 |
20 | class App extends React.PureComponent {
21 | componentDidUpdate(prevProps) {
22 | const {
23 | history: { action },
24 | location: { pathname: prevPathname },
25 | state: {
26 | ui: { isOpenMenu },
27 | },
28 | } = prevProps;
29 | const {
30 | location: { pathname: nextPathname },
31 | actions: {
32 | uiActions: { closeMenu },
33 | },
34 | } = this.props;
35 | const isNotPop = action !== 'POP';
36 | const isChengedPathname = prevPathname !== nextPathname;
37 |
38 | if (isNotPop && isChengedPathname) {
39 | window.scrollTo(0, 0);
40 | if (isOpenMenu) {
41 | closeMenu();
42 | }
43 | }
44 | }
45 |
46 | render() {
47 | const {
48 | location: { pathname },
49 | state: {
50 | ui: { isOpenMenu, error },
51 | },
52 | actions: {
53 | uiActions: { openMenu, closeMenu, hideError },
54 | },
55 | } = this.props;
56 | const metaData = meta.get(pathname);
57 | const linkData = link.get(pathname);
58 |
59 | return (
60 |
61 |
62 |
63 | {error ? {error} : null}
64 |
69 |
70 |
71 | {routes.map((route, i) => (
72 | (
77 |
78 | )}
79 | />
80 | ))}
81 |
82 |
83 |
84 |
85 | );
86 | }
87 | }
88 | const mapStateToProps = (state) => {
89 | return { state };
90 | };
91 | const mapDispatchToProps = () => {
92 | return (dispatch) => ({
93 | dispatch,
94 | actions: {
95 | userActions: bindActionCreators(userActions, dispatch),
96 | uiActions: bindActionCreators(uiActions, dispatch),
97 | },
98 | });
99 | };
100 |
101 | const enhancers = [
102 | withExtendRouter,
103 | connect(mapStateToProps, mapDispatchToProps),
104 | ];
105 |
106 | if (isDevelopment) {
107 | const { hot } = require('react-hot-loader');
108 |
109 | enhancers.push(hot(module));
110 | }
111 |
112 | export default compose(...enhancers)(App);
113 |
--------------------------------------------------------------------------------
/src/components/Container/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 |
4 | const ContainerUI = styled.div`
5 | display: flex;
6 | flex-direction: column;
7 | min-height: 100vh;
8 | `;
9 |
10 | const Container = (props) => {
11 | const { children } = props;
12 |
13 | return {children};
14 | };
15 |
16 | export default Container;
17 |
--------------------------------------------------------------------------------
/src/components/Error/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled, { keyframes } from 'styled-components';
3 | import { colors } from 'styles/variables';
4 |
5 | const flash = keyframes`
6 | 0% { opacity: 0; }
7 | 20% { opacity: 1; }
8 | 80% { opacity: 1; }
9 | 100% { opacity: 0; }
10 | `;
11 | const ErrorUI = styled.div`
12 | position: fixed;
13 | top: 0;
14 | bottom: 0;
15 | display: table;
16 | margin: auto;
17 | padding: 12px;
18 | width: 100%;
19 | box-shadow: 0 1px 23px 0 rgba(0, 0, 0, 0.2);
20 | background-color: ${colors.accent};
21 | color: ${colors.white};
22 | text-align: center;
23 | opacity: 0;
24 | animation: ${flash} 4s ease;
25 | `;
26 |
27 | const Error = (props) => {
28 | const { hideError, children } = props;
29 |
30 | return (
31 | {
33 | hideError();
34 | }}
35 | >
36 | {children}
37 |
38 | );
39 | };
40 |
41 | export default Error;
42 |
--------------------------------------------------------------------------------
/src/components/Error/index.spec.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Error from 'components/Error';
3 | import 'jest-styled-components';
4 |
5 | describe('', () => {
6 | it('render snapshots', () => {
7 | const wrapper = mount(Error);
8 |
9 | expect(wrapper).toMatchInlineSnapshot(`
10 | .c0 {
11 | position: fixed;
12 | top: 0;
13 | bottom: 0;
14 | display: table;
15 | margin: auto;
16 | padding: 12px;
17 | width: 100%;
18 | box-shadow: 0 1px 23px 0 rgba(0,0,0,0.2);
19 | background-color: #f68084;
20 | color: #fff;
21 | text-align: center;
22 | opacity: 0;
23 | -webkit-animation: HIpHm 4s ease;
24 | animation: HIpHm 4s ease;
25 | }
26 |
27 |
28 |
31 |
35 | Error
36 |
37 |
38 |
39 | `);
40 | });
41 | });
42 |
--------------------------------------------------------------------------------
/src/components/Error/index.stories.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { storiesOf } from '@storybook/react';
3 | import Error from 'components/Error';
4 |
5 | storiesOf('Error', module).add('normal', () => Error);
6 |
--------------------------------------------------------------------------------
/src/components/Footer/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import { colors } from 'styles/variables';
4 |
5 | const FooterUI = styled.div`
6 | height: 56px;
7 | margin-top: auto;
8 | padding: 12px 16px;
9 | color: ${colors.white};
10 | background-image: linear-gradient(
11 | 120deg,
12 | ${colors.accent} 0%,
13 | ${colors.link} 100%
14 | );
15 | text-align: center;
16 | line-height: 32px;
17 | `;
18 |
19 | const Footer = () => {
20 | return © 2018 osamu38;
21 | };
22 |
23 | export default Footer;
24 |
--------------------------------------------------------------------------------
/src/components/Footer/index.spec.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Footer from 'components/Footer';
3 | import 'jest-styled-components';
4 |
5 | describe('', () => {
6 | it('render snapshots', () => {
7 | const wrapper = mount();
8 |
9 | expect(wrapper).toMatchInlineSnapshot(`
10 | .c0 {
11 | height: 56px;
12 | padding: 12px 16px;
13 | color: #fff;
14 | background-image: linear-gradient( 120deg, #f68084 0%, #a6c0fe 100% );
15 | text-align: center;
16 | line-height: 32px;
17 | }
18 |
19 |
28 | `);
29 | });
30 | });
31 |
--------------------------------------------------------------------------------
/src/components/Footer/index.stories.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { storiesOf } from '@storybook/react';
3 | import Footer from 'components/Footer';
4 |
5 | storiesOf('Footer', module).add('normal', () => );
6 |
--------------------------------------------------------------------------------
/src/components/Header/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import { sizes, spaces, colors } from 'styles/variables';
4 | import Logo from 'components/Logo';
5 | import Menu from 'components/Menu';
6 | import MenuIcon from 'components/MenuIcon';
7 | import OctIcon from 'components/OctIcon';
8 |
9 | const HeaderContainer = styled.div`
10 | height: 56px;
11 | `;
12 | const HeaderUI = styled.div`
13 | position: fixed;
14 | top: 0;
15 | left: 0;
16 | width: 100%;
17 | height: 56px;
18 | background-image: linear-gradient(
19 | 120deg,
20 | ${colors.link} 0%,
21 | ${colors.accent} 100%
22 | );
23 | `;
24 | const HeaderInner = styled.div`
25 | position: relative;
26 | display: flex;
27 | justify-content: space-between;
28 | padding: 12px 16px;
29 | margin: 0 auto;
30 | max-width: ${sizes.width.main + spaces.main * 2}px;
31 | width: 100%;
32 | `;
33 | const StyledOctIcon = styled(OctIcon)`
34 | margin-right: 12px;
35 | `;
36 |
37 | const Header = (props) => {
38 | const { isOpenMenu, openMenu, closeMenu } = props;
39 |
40 | return (
41 |
42 |
43 |
44 |
45 |
46 |
47 |
52 |
53 |
54 |
55 |
56 |
57 | );
58 | };
59 |
60 | export default Header;
61 |
--------------------------------------------------------------------------------
/src/components/Header/index.spec.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Header from 'components/Header';
3 | import 'jest-styled-components';
4 |
5 | describe('', () => {
6 | it('render snapshots', () => {
7 | const wrapper = shallow();
8 |
9 | expect(wrapper).toMatchInlineSnapshot(`
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | `);
27 | });
28 | });
29 |
--------------------------------------------------------------------------------
/src/components/Header/index.stories.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { storiesOf } from '@storybook/react';
3 | import { linkTo } from '@storybook/addon-links';
4 | import routerDecorator from 'tools/storybook/routerDecorator';
5 | import Header from 'components/Header';
6 |
7 | storiesOf('Header', module)
8 | .addDecorator(routerDecorator)
9 | .add('normal', () => )
10 | .add('open menu', () => (
11 |
12 | ));
13 |
--------------------------------------------------------------------------------
/src/components/Logo/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import PreloadLink from 'components/PreloadLink';
4 | import { colors } from 'styles/variables';
5 | import { endpoint } from 'config/url';
6 |
7 | const LogoUI = styled.a`
8 | font-size: 20px;
9 | font-weight: bold;
10 | color: ${colors.white};
11 | line-height: 32px;
12 | `;
13 |
14 | const Logo = () => {
15 | return (
16 |
17 | React SSR Starter
18 |
19 | );
20 | };
21 |
22 | export default Logo;
23 |
--------------------------------------------------------------------------------
/src/components/Logo/index.stories.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { storiesOf } from '@storybook/react';
3 | import routerDecorator from 'tools/storybook/routerDecorator';
4 | import backgroundDecorator from 'tools/storybook/backgroundDecorator';
5 | import Logo from 'components/Logo';
6 |
7 | storiesOf('Logo', module)
8 | .addDecorator(routerDecorator)
9 | .addDecorator(backgroundDecorator)
10 | .add('normal', () => );
11 |
--------------------------------------------------------------------------------
/src/components/Main/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import { sizes, spaces } from 'styles/variables';
4 |
5 | const MainUI = styled.div`
6 | margin: 0 auto;
7 | padding: ${spaces.main}px;
8 | width: 100%;
9 | max-width: ${sizes.width.main + spaces.main * 2}px;
10 | `;
11 |
12 | const Main = (props) => {
13 | const { children } = props;
14 |
15 | return {children};
16 | };
17 |
18 | export default Main;
19 |
--------------------------------------------------------------------------------
/src/components/Menu/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import PreloadLink from 'components/PreloadLink';
4 | import { colors } from 'styles/variables';
5 | import { endpoint } from 'config/url';
6 |
7 | const MenuUI = styled.div`
8 | position: absolute;
9 | top: 56px;
10 | right: 0;
11 | max-width: 320px;
12 | width: 100%;
13 | padding: 12px;
14 | border: 1px ${colors.superLightGray} solid;
15 | background-color: ${colors.white};
16 | `;
17 | const MenuLink = styled.a`
18 | display: block;
19 | color: ${colors.link};
20 | &.active {
21 | color: ${colors.accent};
22 | }
23 | &:not(:first-child) {
24 | margin-top: 12px;
25 | }
26 | &:hover {
27 | text-decoration: underline;
28 | }
29 | `;
30 | const MenuLinkList = [
31 | {
32 | to: endpoint.home,
33 | text: 'To Home Page',
34 | },
35 | {
36 | to: endpoint.about,
37 | text: 'To About Page',
38 | },
39 | {
40 | to: '/unknown',
41 | text: 'To Not Exist Url',
42 | },
43 | ];
44 |
45 | const Menu = (props) => {
46 | const { isOpenMenu } = props;
47 |
48 | return isOpenMenu ? (
49 |
50 | {MenuLinkList.map((item, i) => (
51 |
52 | {item.text}
53 |
54 | ))}
55 |
56 | ) : null;
57 | };
58 |
59 | export default Menu;
60 |
--------------------------------------------------------------------------------
/src/components/Menu/index.spec.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Menu from 'components/Menu';
3 | import 'jest-styled-components';
4 |
5 | describe('', () => {
6 | it('render snapshots', () => {
7 | const wrapper = mount();
8 |
9 | expect(wrapper).toMatchInlineSnapshot(``);
10 | });
11 | });
12 |
--------------------------------------------------------------------------------
/src/components/Menu/index.stories.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { storiesOf } from '@storybook/react';
3 | import routerDecorator from 'tools/storybook/routerDecorator';
4 | import Menu from 'components/Menu';
5 |
6 | storiesOf('Menu', module)
7 | .addDecorator(routerDecorator)
8 | .add('normal', () => );
9 |
--------------------------------------------------------------------------------
/src/components/MenuIcon/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 |
4 | const MenuIconWrapper = styled.span`
5 | cursor: pointer;
6 | `;
7 | const MenuIconUI = styled.img`
8 | padding: 4px;
9 | width: 32px;
10 | `;
11 |
12 | const MenuIcon = (props) => {
13 | const { isOpenMenu, openMenu, closeMenu } = props;
14 |
15 | return (
16 | {
18 | if (isOpenMenu) {
19 | closeMenu();
20 | } else {
21 | openMenu();
22 | }
23 | }}
24 | >
25 |
26 |
27 | );
28 | };
29 |
30 | export default MenuIcon;
31 |
--------------------------------------------------------------------------------
/src/components/MenuIcon/index.spec.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import MenuIcon from 'components/MenuIcon';
3 | import 'jest-styled-components';
4 |
5 | describe('', () => {
6 | it('render snapshots', () => {
7 | const wrapper = mount(MenuIcon);
8 |
9 | expect(wrapper).toMatchInlineSnapshot(`
10 | .c0 {
11 | cursor: pointer;
12 | }
13 |
14 | .c1 {
15 | padding: 4px;
16 | width: 32px;
17 | }
18 |
19 |
20 |
23 |
27 |
31 |
36 |
37 |
38 |
39 |
40 | `);
41 | });
42 | });
43 |
--------------------------------------------------------------------------------
/src/components/MenuIcon/index.stories.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { storiesOf } from '@storybook/react';
3 | import MenuIcon from 'components/MenuIcon';
4 | import backgroundDecorator from 'tools/storybook/backgroundDecorator';
5 |
6 | storiesOf('MenuIcon', module)
7 | .addDecorator(backgroundDecorator)
8 | .add('normal', () => );
9 |
--------------------------------------------------------------------------------
/src/components/OctIcon/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const OctIcon = (props) => {
4 | const { className } = props;
5 |
6 | return (
7 |
13 |
14 |
15 | );
16 | };
17 |
18 | export default OctIcon;
19 |
--------------------------------------------------------------------------------
/src/components/OctIcon/index.spec.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import OctIcon from 'components/OctIcon';
3 | import 'jest-styled-components';
4 |
5 | describe('', () => {
6 | it('render snapshots', () => {
7 | const wrapper = mount(OctIcon);
8 |
9 | expect(wrapper).toMatchInlineSnapshot(`
10 |
11 |
16 |
20 |
21 |
22 | `);
23 | });
24 | });
25 |
--------------------------------------------------------------------------------
/src/components/OctIcon/index.stories.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { storiesOf } from '@storybook/react';
3 | import OctIcon from 'components/OctIcon';
4 | import backgroundDecorator from 'tools/storybook/backgroundDecorator';
5 |
6 | storiesOf('OctIcon', module)
7 | .addDecorator(backgroundDecorator)
8 | .add('normal', () => );
9 |
--------------------------------------------------------------------------------
/src/components/PreloadLink/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { compose } from 'redux';
3 | import { connect } from 'react-redux';
4 | import withExtendRouter from 'components/withExtendRouter';
5 | import customPush from 'utils/customPush';
6 |
7 | const mapStateToProps = (state) => {
8 | return { state };
9 | };
10 | const mapDispatchToProps = () => {
11 | return (dispatch) => ({
12 | dispatch,
13 | });
14 | };
15 |
16 | const PreloadLink = (props) => {
17 | const {
18 | href,
19 | children,
20 | history: { push },
21 | dispatch,
22 | state,
23 | } = props;
24 |
25 | return React.cloneElement(children, {
26 | href,
27 | onClick: async (e) => {
28 | e.preventDefault();
29 | await customPush(href, push, dispatch, state);
30 | },
31 | });
32 | };
33 |
34 | const enhancers = [
35 | withExtendRouter,
36 | connect(mapStateToProps, mapDispatchToProps),
37 | ];
38 |
39 | export default compose(...enhancers)(PreloadLink);
40 |
--------------------------------------------------------------------------------
/src/components/StackList/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import { colors } from 'styles/variables';
4 |
5 | const StackListUI = styled.ul`
6 | margin: -8px 0 0 -8px;
7 | `;
8 | const StackListItem = styled.li`
9 | display: inline-block;
10 | margin: 8px 0 0 8px;
11 | `;
12 | const StackListLink = styled.a`
13 | display: inline-block;
14 | padding: 8px 12px;
15 | color: ${colors.link};
16 | border: 1px ${colors.superLightGray} solid;
17 | &:hover {
18 | text-decoration: underline;
19 | }
20 | `;
21 |
22 | const stackData = [
23 | {
24 | name: 'react',
25 | link: 'https://reactjs.org/',
26 | },
27 | {
28 | name: 'react-router',
29 | link: 'https://reacttraining.com/react-router/',
30 | },
31 | {
32 | name: 'react-helmet-async',
33 | link: 'https://github.com/staylor/react-helmet-async/',
34 | },
35 | {
36 | name: 'react-hot-loader',
37 | link: 'http://gaearon.github.io/react-hot-loader/',
38 | },
39 | {
40 | name: 'redux',
41 | link: 'https://redux.js.org/',
42 | },
43 | {
44 | name: 'styled-components',
45 | link: 'https://www.styled-components.com/',
46 | },
47 | {
48 | name: 'loadable-components',
49 | link: 'https://www.smooth-code.com/open-source/loadable-components/',
50 | },
51 | {
52 | name: 'express',
53 | link: 'http://expressjs.com/',
54 | },
55 | {
56 | name: 'workbox',
57 | link: 'https://developers.google.com/web/tools/workbox/',
58 | },
59 | {
60 | name: 'eslint',
61 | link: 'https://eslint.org/',
62 | },
63 | {
64 | name: 'stylelint',
65 | link: 'https://stylelint.io/',
66 | },
67 | {
68 | name: 'prettier',
69 | link: 'https://prettier.io/',
70 | },
71 | {
72 | name: 'jest',
73 | link: 'https://facebook.github.io/jest/',
74 | },
75 | {
76 | name: 'enzyme',
77 | link: 'http://airbnb.io/enzyme/',
78 | },
79 | {
80 | name: 'storybook',
81 | link: 'https://storybook.js.org/',
82 | },
83 | {
84 | name: 'webpack',
85 | link: 'https://webpack.js.org/',
86 | },
87 | {
88 | name: 'babel',
89 | link: 'https://babeljs.io/',
90 | },
91 | ];
92 |
93 | const StackList = () => {
94 | return (
95 |
96 | {stackData.map((item, i) => (
97 |
98 |
99 | {item.name}
100 |
101 |
102 | ))}
103 |
104 | );
105 | };
106 |
107 | export default StackList;
108 |
--------------------------------------------------------------------------------
/src/components/StackList/index.spec.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import StackList from 'components/StackList';
3 | import 'jest-styled-components';
4 |
5 | describe('', () => {
6 | it('render snapshots', () => {
7 | const wrapper = mount();
8 |
9 | expect(wrapper).toMatchInlineSnapshot(`
10 | .c0 {
11 | margin: -8px 0 0 -8px;
12 | }
13 |
14 | .c1 {
15 | display: inline-block;
16 | margin: 8px 0 0 8px;
17 | }
18 |
19 | .c2 {
20 | display: inline-block;
21 | padding: 8px 12px;
22 | color: #a6c0fe;
23 | border: 1px #eee solid;
24 | }
25 |
26 | .c2:hover {
27 | -webkit-text-decoration: underline;
28 | text-decoration: underline;
29 | }
30 |
31 |
32 |
33 |
433 |
434 |
435 | `);
436 | });
437 | });
438 |
--------------------------------------------------------------------------------
/src/components/StackList/index.stories.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { storiesOf } from '@storybook/react';
3 | import StackList from 'components/StackList';
4 |
5 | storiesOf('StackList', module).add('normal', () => );
6 |
--------------------------------------------------------------------------------
/src/components/SubTitle/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import { colors } from 'styles/variables';
4 |
5 | const SubTitleUI = styled.div`
6 | margin-bottom: 12px;
7 | font-size: 20px;
8 | font-weight: bold;
9 | color: ${colors.darkGray};
10 | `;
11 |
12 | const SubTitle = (props) => {
13 | const { children } = props;
14 |
15 | return {children};
16 | };
17 |
18 | export default SubTitle;
19 |
--------------------------------------------------------------------------------
/src/components/SubTitle/index.spec.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import SubTitle from 'components/SubTitle';
3 | import 'jest-styled-components';
4 |
5 | describe('', () => {
6 | it('render snapshots', () => {
7 | const wrapper = mount(SubTitle);
8 |
9 | expect(wrapper).toMatchInlineSnapshot(`
10 | .c0 {
11 | margin-bottom: 12px;
12 | font-size: 20px;
13 | font-weight: bold;
14 | color: #666;
15 | }
16 |
17 |
18 |
19 |
22 | SubTitle
23 |
24 |
25 |
26 | `);
27 | });
28 | });
29 |
--------------------------------------------------------------------------------
/src/components/SubTitle/index.stories.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { storiesOf } from '@storybook/react';
3 | import SubTitle from 'components/SubTitle';
4 |
5 | storiesOf('SubTitle', module).add('normal', () => (
6 | SubTitle
7 | ));
8 |
--------------------------------------------------------------------------------
/src/components/Title/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import { colors } from 'styles/variables';
4 |
5 | const TitleUI = styled.h1`
6 | margin-bottom: 12px;
7 | font-size: 24px;
8 | font-weight: bold;
9 | color: ${colors.accent};
10 | `;
11 |
12 | const Title = (props) => {
13 | const { children } = props;
14 |
15 | return {children};
16 | };
17 |
18 | export default Title;
19 |
--------------------------------------------------------------------------------
/src/components/Title/index.spec.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Title from 'components/Title';
3 | import 'jest-styled-components';
4 |
5 | describe('', () => {
6 | it('render snapshots', () => {
7 | const wrapper = mount(Title);
8 |
9 | expect(wrapper).toMatchInlineSnapshot(`
10 | .c0 {
11 | margin-bottom: 12px;
12 | font-size: 24px;
13 | font-weight: bold;
14 | color: #f68084;
15 | }
16 |
17 |
18 |
19 |
22 | Title
23 |
24 |
25 |
26 | `);
27 | });
28 | });
29 |
--------------------------------------------------------------------------------
/src/components/Title/index.stories.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { storiesOf } from '@storybook/react';
3 | import Title from 'components/Title';
4 |
5 | storiesOf('Title', module).add('normal', () => Title);
6 |
--------------------------------------------------------------------------------
/src/components/UserDetail/data.js:
--------------------------------------------------------------------------------
1 | export default {
2 | id: 1,
3 | username: 'Bret',
4 | email: 'Sincere@april.biz',
5 | address: {
6 | street: 'Kulas Light',
7 | suite: 'Apt. 556',
8 | city: 'Gwenborough',
9 | zipcode: '92998-3874',
10 | geo: {
11 | lat: '-37.3159',
12 | lng: '81.1496',
13 | },
14 | },
15 | phone: '1-770-736-8031 x56442',
16 | website: 'hildegard.org',
17 | company: {
18 | name: 'Romaguera-Crona',
19 | catchPhrase: 'Multi-layered client-server neural-net',
20 | bs: 'harness real-time e-markets',
21 | },
22 | };
23 |
--------------------------------------------------------------------------------
/src/components/UserDetail/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import { colors } from 'styles/variables';
4 |
5 | const UserDetailUI = styled.div`
6 | padding: 12px;
7 | border: 1px ${colors.superLightGray} solid;
8 | `;
9 | const UserDetailInner = styled.div`
10 | margin-top: 12px;
11 | padding: 12px;
12 | border: 1px ${colors.superLightGray} solid;
13 | `;
14 |
15 | const UserDetail = (props) => {
16 | const {
17 | user: {
18 | id,
19 | username,
20 | phone,
21 | email,
22 | website,
23 | address: {
24 | city,
25 | street,
26 | suite,
27 | zipcode,
28 | geo: { lat, lng },
29 | },
30 | company: { bs, catchPhrase, name },
31 | },
32 | } = props;
33 |
34 | return (
35 |
36 | id: {id}
37 | username: {username}
38 | phone: {phone}
39 | email: {email}
40 | website: {website}
41 |
42 | address
43 | city: {city}
44 | street: {street}
45 | suite: {suite}
46 | zipcode: {zipcode}
47 | lat: {lat}
48 | lng: {lng}
49 |
50 |
51 | company
52 | bs: {bs}
53 | catchPhrase: {catchPhrase}
54 | name: {name}
55 |
56 |
57 | );
58 | };
59 |
60 | export default UserDetail;
61 |
--------------------------------------------------------------------------------
/src/components/UserDetail/index.spec.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import UserDetail from 'components/UserDetail';
3 | import userData from 'components/UserDetail/data';
4 | import 'jest-styled-components';
5 |
6 | describe('', () => {
7 | it('render snapshots', () => {
8 | const wrapper = mount();
9 |
10 | expect(wrapper).toMatchInlineSnapshot(`
11 | .c0 {
12 | padding: 12px;
13 | border: 1px #eee solid;
14 | }
15 |
16 | .c1 {
17 | margin-top: 12px;
18 | padding: 12px;
19 | border: 1px #eee solid;
20 | }
21 |
22 |
48 |
49 |
52 |
53 | id:
54 | 1
55 |
56 |
57 | username:
58 | Bret
59 |
60 |
61 | phone:
62 | 1-770-736-8031 x56442
63 |
64 |
65 | email:
66 | Sincere@april.biz
67 |
68 |
69 | website:
70 | hildegard.org
71 |
72 |
73 |
76 |
77 | address
78 |
79 |
80 | city:
81 | Gwenborough
82 |
83 |
84 | street:
85 | Kulas Light
86 |
87 |
88 | suite:
89 | Apt. 556
90 |
91 |
92 | zipcode:
93 | 92998-3874
94 |
95 |
96 | lat:
97 | -37.3159
98 |
99 |
100 | lng:
101 | 81.1496
102 |
103 |
104 |
105 |
106 |
109 |
110 | company
111 |
112 |
113 | bs:
114 | harness real-time e-markets
115 |
116 |
117 | catchPhrase:
118 | Multi-layered client-server neural-net
119 |
120 |
121 | name:
122 | Romaguera-Crona
123 |
124 |
125 |
126 |
127 |
128 |
129 | `);
130 | });
131 | });
132 |
--------------------------------------------------------------------------------
/src/components/UserDetail/index.stories.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { storiesOf } from '@storybook/react';
3 | import UserDetail from 'components/UserDetail';
4 | import userData from 'components/UserDetail/data';
5 |
6 | storiesOf('UserDetail', module).add('normal', () => (
7 |
8 | ));
9 |
--------------------------------------------------------------------------------
/src/components/UserList/data.js:
--------------------------------------------------------------------------------
1 | export default [
2 | {
3 | name: 'Leanne Graham',
4 | },
5 | {
6 | name: 'Ervin Howell',
7 | },
8 | {
9 | name: 'Clementine Bauch',
10 | },
11 | ];
12 |
--------------------------------------------------------------------------------
/src/components/UserList/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import { colors } from 'styles/variables';
4 | import media from 'styles/media';
5 | import PreloadLink from 'components/PreloadLink';
6 |
7 | const UserListUI = styled.ul`
8 | width: 100%;
9 | border: 1px ${colors.superLightGray} solid;
10 | `;
11 | const UserListItem = styled.li`
12 | display: inline-block;
13 | width: 33.33333%;
14 | border-bottom: 1px ${colors.superLightGray} solid;
15 | border-right: 1px ${colors.superLightGray} solid;
16 | &:nth-child(3n) {
17 | border-right: none;
18 | }
19 | &:last-child:nth-child(3n),
20 | &:last-child:nth-child(3n-1),
21 | &:last-child:nth-child(3n-2),
22 | &:nth-last-child(2):nth-child(3n-1),
23 | &:nth-last-child(2):nth-child(3n-2),
24 | &:nth-last-child(3):nth-child(3n-2) {
25 | border-bottom: none;
26 | }
27 |
28 | ${media.tablet} {
29 | width: 50%;
30 | &:nth-child(3n) {
31 | border-right: 1px ${colors.superLightGray} solid;
32 | }
33 | &:nth-child(2n) {
34 | border-right: none;
35 | }
36 | &:nth-last-child(2):nth-child(2n-1) {
37 | border-bottom: none;
38 | }
39 | &:nth-last-child(2):nth-child(3n-1),
40 | &:nth-last-child(3):nth-child(3n-2) {
41 | border-bottom: 1px ${colors.superLightGray} solid;
42 | }
43 | }
44 | ${media.phone} {
45 | width: 100%;
46 | &:nth-child(n) {
47 | border-right: none;
48 | }
49 | &:nth-last-child(2):nth-child(2n-1) {
50 | border-bottom: 1px ${colors.superLightGray} solid;
51 | }
52 | }
53 | `;
54 | const UserListLink = styled.a`
55 | display: block;
56 | padding: 8px;
57 | color: ${colors.link};
58 | &:hover {
59 | text-decoration: underline;
60 | }
61 | `;
62 |
63 | const UserList = (props) => {
64 | const { userList } = props;
65 |
66 | return (
67 |
68 | {userList.map((item, i) => (
69 |
70 |
71 | {item.name}
72 |
73 |
74 | ))}
75 |
76 | );
77 | };
78 |
79 | export default UserList;
80 |
--------------------------------------------------------------------------------
/src/components/UserList/index.stories.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { storiesOf } from '@storybook/react';
3 | import routerDecorator from 'tools/storybook/routerDecorator';
4 | import UserList from 'components/UserList';
5 | import userList from 'components/UserList/data';
6 |
7 | storiesOf('UserList', module)
8 | .addDecorator(routerDecorator)
9 | .add('normal', () => );
10 |
--------------------------------------------------------------------------------
/src/components/withExtendRouter/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { withRouter } from 'react-router-dom';
3 | import { parse } from 'query-string';
4 |
5 | const withExtendRouter = (Component) => {
6 | const HOC = (props) => {
7 | const { location } = props;
8 | const { search } = location;
9 | const query = parse(search, { arrayFormat: 'bracket' });
10 |
11 | Object.assign(location, { query });
12 |
13 | return ;
14 | };
15 | return withRouter(HOC);
16 | };
17 |
18 | export default withExtendRouter;
19 |
--------------------------------------------------------------------------------
/src/config/env.js:
--------------------------------------------------------------------------------
1 | export const nodeEnv = process.env.NODE_ENV || '';
2 | export const env = nodeEnv || 'development';
3 | export const isDevelopment = env === 'development';
4 | export const isStaging = env === 'staging';
5 | export const isProduction = env === 'production';
6 | export const isServer = typeof document === 'undefined';
7 | export const isClient = !isServer;
8 |
--------------------------------------------------------------------------------
/src/config/url.js:
--------------------------------------------------------------------------------
1 | import { isDevelopment } from 'config/env';
2 |
3 | export const port = 2525;
4 | export const origin = isDevelopment
5 | ? `http://localhost:${port}`
6 | : process.env.HEROKU_DOMAIN || `http://localhost:${port}`;
7 | export const endpoint = {
8 | home: '/',
9 | userDetail: '/users/:id',
10 | about: '/about',
11 | redirectAbout: '/redirect-about',
12 | notFound: '*',
13 | };
14 |
--------------------------------------------------------------------------------
/src/pages/about/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Helmet } from 'react-helmet-async';
3 | import Title from 'components/Title';
4 | import SubTitle from 'components/SubTitle';
5 | import StackList from 'components/StackList';
6 |
7 | const AboutPage = () => {
8 | return (
9 | <>
10 |
11 | About Page
12 | Use Stack List
13 |
14 | >
15 | );
16 | };
17 |
18 | export default AboutPage;
19 |
--------------------------------------------------------------------------------
/src/pages/home/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Helmet } from 'react-helmet-async';
3 | import Title from 'components/Title';
4 | import SubTitle from 'components/SubTitle';
5 | import UserList from 'components/UserList';
6 | import { fetchUsers } from 'actions/user';
7 |
8 | const HomePage = (props) => {
9 | const {
10 | state: {
11 | user: { userList },
12 | },
13 | } = props;
14 |
15 | return (
16 | <>
17 |
18 | Home Page
19 | User List
20 |
21 | >
22 | );
23 | };
24 |
25 | HomePage.loadData = (ctx) => {
26 | const {
27 | dispatch,
28 | state: {
29 | user: { userList },
30 | },
31 | } = ctx;
32 |
33 | if (!userList.length) {
34 | return dispatch(fetchUsers());
35 | }
36 | return Promise.resolve();
37 | };
38 |
39 | export default HomePage;
40 |
--------------------------------------------------------------------------------
/src/pages/notFound/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Helmet } from 'react-helmet-async';
3 | import Title from 'components/Title';
4 |
5 | const NotFoundPage = () => {
6 | return (
7 | <>
8 |
9 | NotFound
10 | >
11 | );
12 | };
13 |
14 | export default NotFoundPage;
15 |
--------------------------------------------------------------------------------
/src/pages/redirectAbout/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Helmet } from 'react-helmet-async';
3 | import { endpoint } from 'config/url';
4 |
5 | const RedirectAboutPage = () => {
6 | return ;
7 | };
8 |
9 | RedirectAboutPage.getRedirectUrl = () => {
10 | return endpoint.about;
11 | };
12 |
13 | export default RedirectAboutPage;
14 |
--------------------------------------------------------------------------------
/src/pages/userDetail/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Helmet } from 'react-helmet-async';
3 | import Title from 'components/Title';
4 | import UserDetail from 'components/UserDetail';
5 | import { fetchUser } from 'actions/user';
6 |
7 | const UserDetailPage = (props) => {
8 | const {
9 | state: {
10 | user: { user },
11 | },
12 | } = props;
13 |
14 | return (
15 | <>
16 |
17 | User Detail Page
18 |
19 | >
20 | );
21 | };
22 |
23 | UserDetailPage.loadData = ({ dispatch, params }) => {
24 | return dispatch(fetchUser(params.id));
25 | };
26 |
27 | export default UserDetailPage;
28 |
--------------------------------------------------------------------------------
/src/reducers/index.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from 'redux';
2 | import user from 'reducers/user';
3 | import ui from 'reducers/ui';
4 |
5 | const reducers = {
6 | user,
7 | ui,
8 | };
9 |
10 | export default combineReducers(reducers);
11 |
--------------------------------------------------------------------------------
/src/reducers/ui.js:
--------------------------------------------------------------------------------
1 | export const initialState = {
2 | isOpenMenu: false,
3 | error: '',
4 | };
5 | export default (state = initialState, action) => {
6 | switch (action.type) {
7 | case 'OPEN_MENU':
8 | return {
9 | ...state,
10 | isOpenMenu: true,
11 | };
12 | case 'CLOSE_MENU':
13 | return {
14 | ...state,
15 | isOpenMenu: false,
16 | };
17 | case 'SHOW_ERROR':
18 | return {
19 | ...state,
20 | ...action.payload,
21 | };
22 | case 'HIDE_ERROR':
23 | return {
24 | ...state,
25 | error: '',
26 | };
27 | default:
28 | return state;
29 | }
30 | };
31 |
--------------------------------------------------------------------------------
/src/reducers/user.js:
--------------------------------------------------------------------------------
1 | export const initialState = {
2 | user: {
3 | address: {
4 | city: '',
5 | geo: {
6 | lat: '',
7 | lng: '',
8 | },
9 | street: '',
10 | suite: '',
11 | zipcode: '',
12 | },
13 | company: {
14 | bs: '',
15 | catchPhrase: '',
16 | name: '',
17 | },
18 | email: '',
19 | id: 0,
20 | name: '',
21 | phone: '',
22 | username: '',
23 | website: '',
24 | },
25 | userList: [],
26 | status: {
27 | isFetchedUserList: false,
28 | },
29 | };
30 | export default (state = initialState, action) => {
31 | switch (action.type) {
32 | case 'FETCH_USER':
33 | return {
34 | ...state,
35 | user: action.payload,
36 | };
37 | case 'FETCH_USERS':
38 | return {
39 | ...state,
40 | userList: action.payload,
41 | status: {
42 | ...state.status,
43 | isFetchedUserList: true,
44 | },
45 | };
46 | default:
47 | return state;
48 | }
49 | };
50 |
--------------------------------------------------------------------------------
/src/routes.js:
--------------------------------------------------------------------------------
1 | import loadable from '@loadable/component';
2 | import { endpoint } from 'config/url';
3 |
4 | const Home = loadable(() => import(/* webpackPrefetch: true */ 'pages/home'));
5 | const UserDetail = loadable(() =>
6 | import(/* webpackPrefetch: true */ 'pages/userDetail')
7 | );
8 | const About = loadable(() => import(/* webpackPrefetch: true */ 'pages/about'));
9 | const RedirectAbout = loadable(() =>
10 | import(/* webpackPrefetch: true */ 'pages/redirectAbout')
11 | );
12 | const NotFound = loadable(() =>
13 | import(/* webpackPrefetch: true */ 'pages/notFound')
14 | );
15 |
16 | const addExact = (routes) => {
17 | return routes.map((route) =>
18 | route.path !== endpoint.notFound
19 | ? {
20 | ...route,
21 | exact: true,
22 | }
23 | : route
24 | );
25 | };
26 |
27 | export default addExact([
28 | {
29 | path: endpoint.home,
30 | component: Home,
31 | },
32 | {
33 | path: endpoint.userDetail,
34 | component: UserDetail,
35 | },
36 | {
37 | path: endpoint.about,
38 | component: About,
39 | },
40 | {
41 | path: endpoint.redirectAbout,
42 | component: RedirectAbout,
43 | },
44 | {
45 | path: endpoint.notFound,
46 | component: NotFound,
47 | },
48 | ]);
49 |
--------------------------------------------------------------------------------
/src/server.js:
--------------------------------------------------------------------------------
1 | import express from 'express';
2 | import logger from 'morgan';
3 | import cookieParser from 'cookie-parser';
4 | import bodyParser from 'body-parser';
5 | import compression from 'compression';
6 | import helmet from 'helmet';
7 | import hpp from 'hpp';
8 | import favicon from 'serve-favicon';
9 | import React from 'react';
10 | import { renderToString } from 'react-dom/server';
11 | import { StaticRouter } from 'react-router-dom';
12 | import { matchRoutes } from 'react-router-config';
13 | import { HelmetProvider } from 'react-helmet-async';
14 | import { Provider } from 'react-redux';
15 | import { ChunkExtractor } from '@loadable/server';
16 | import { ServerStyleSheet } from 'styled-components';
17 | import routes from 'routes';
18 | import configureStore from 'utils/configureStore';
19 | import getHtmlString from 'utils/getHtmlString';
20 | import getPreloadResorceElement from 'utils/getPreloadResorceElement';
21 | import App from 'components/App';
22 | import { isDevelopment } from 'config/env';
23 | import { joinPath } from 'utils/path';
24 | import { port as defaultPort } from 'config/url';
25 |
26 | const port = process.env.PORT || defaultPort;
27 | const app = express();
28 |
29 | const getLoadBranchData = (branch, store, query) => {
30 | return branch
31 | .filter(({ route }) => route.component.loadData)
32 | .map(({ route, match }) =>
33 | route.component.loadData({
34 | dispatch: store.dispatch,
35 | state: store.getState(),
36 | params: match.params,
37 | query,
38 | route,
39 | })
40 | );
41 | };
42 | const loadComponents = (branch) => {
43 | return Promise.all(
44 | branch.map(({ route }) => {
45 | if (route.component.load) {
46 | return route.component.load();
47 | }
48 | return Promise.resolve();
49 | })
50 | );
51 | };
52 | const getBranchWithLoadedComponents = (branch, loadedComponents) => {
53 | return loadedComponents.map((component, index) => ({
54 | ...branch[index],
55 | route: {
56 | ...branch[index].route,
57 | ...(component && {
58 | component: component.default,
59 | }),
60 | },
61 | }));
62 | };
63 | const getRedirectUrls = (branch, store, query) => {
64 | return branch
65 | .filter(({ route }) => route.component.getRedirectUrl)
66 | .map(({ route, match }) =>
67 | route.component.getRedirectUrl({
68 | dispatch: store.dispatch,
69 | state: store.getState(),
70 | params: match.params,
71 | query,
72 | route,
73 | })
74 | )
75 | .filter((location) => location);
76 | };
77 | const getExtractor = () => {
78 | const statsFile = joinPath(
79 | !isDevelopment ? 'dist' : '',
80 | 'public/static/javascripts/loadable-stats.json'
81 | );
82 | const extractor = new ChunkExtractor({ statsFile });
83 |
84 | return extractor;
85 | };
86 |
87 | app.use(helmet());
88 | app.use(hpp());
89 | app.use(compression());
90 | app.use(logger('dev'));
91 | app.use(bodyParser.json());
92 | app.use(bodyParser.urlencoded({ extended: false }));
93 | app.use(cookieParser());
94 | app.use(
95 | express.static(joinPath(!isDevelopment ? 'dist' : '', 'public'), {
96 | setHeaders: (res) => {
97 | res.set('Service-Worker-Allowed', '/');
98 | },
99 | })
100 | );
101 | app.use(favicon(joinPath(!isDevelopment ? 'dist' : '', 'public/favicon.ico')));
102 |
103 | if (isDevelopment) {
104 | const webpack = require('webpack');
105 | const webpackDevMiddleware = require('webpack-dev-middleware');
106 | const webpackHotMiddleware = require('webpack-hot-middleware');
107 | const getWebpackClientConfig = require('tools/webpack/webpack.client.babel');
108 | const webpackClientConfig = getWebpackClientConfig({});
109 | const compiler = webpack(webpackClientConfig);
110 |
111 | app.use(
112 | webpackDevMiddleware(compiler, {
113 | publicPath: webpackClientConfig.output.publicPath,
114 | hot: true,
115 | stats: 'none',
116 | serverSideRender: true,
117 | writeToDisk(filePath) {
118 | return /loadable-stats/.test(filePath);
119 | },
120 | })
121 | );
122 | app.use(
123 | webpackHotMiddleware(compiler, {
124 | log: false,
125 | })
126 | );
127 | }
128 |
129 | app.get('*', async (req, res) => {
130 | const store = configureStore();
131 | const branch = matchRoutes(routes, req.path);
132 | const loadedComponents = await loadComponents(branch);
133 | const branchWithLoadedComponents = getBranchWithLoadedComponents(
134 | branch,
135 | loadedComponents
136 | );
137 | const loadBranchData = getLoadBranchData(
138 | branchWithLoadedComponents,
139 | store,
140 | req.query
141 | );
142 |
143 | Promise.all(loadBranchData)
144 | .then(async () => {
145 | const redirectUrls = getRedirectUrls(
146 | branchWithLoadedComponents,
147 | store,
148 | req.query
149 | );
150 |
151 | if (redirectUrls.length) {
152 | res.redirect(redirectUrls[0]);
153 | } else {
154 | const helmetContext = {};
155 | const initialState = store.getState();
156 | const sheet = new ServerStyleSheet();
157 | const AppComponent = (
158 |
159 |
160 |
161 |
162 |
163 |
164 |
165 | );
166 | const extractor = getExtractor();
167 | const jsx = extractor.collectChunks(AppComponent);
168 | const content = renderToString(sheet.collectStyles(jsx));
169 | const css = sheet.getStyleElement();
170 | const styleTags = sheet.getStyleTags();
171 | const { helmet: head } = helmetContext;
172 | const scriptElements = extractor.getScriptElements();
173 | const preloadResorceElement = getPreloadResorceElement(
174 | content,
175 | styleTags
176 | );
177 | const htmlString = getHtmlString(
178 | css,
179 | head,
180 | content,
181 | initialState,
182 | scriptElements,
183 | preloadResorceElement
184 | );
185 | const document = `${htmlString}`;
186 |
187 | res.status(200).send(document);
188 | }
189 | })
190 | .catch((err) => {
191 | // eslint-disable-next-line no-console
192 | console.error(`==> 😭 Rendering routes error: ${err}`);
193 | });
194 | });
195 |
196 | app.listen(port, (err) => {
197 | if (err) {
198 | // eslint-disable-next-line no-console
199 | console.error(err);
200 | }
201 | // eslint-disable-next-line no-console
202 | console.info('==> 🌎 Listening on port %s.', port);
203 | });
204 |
205 | process.on('unhandledRejection', (err) => {
206 | // eslint-disable-next-line no-console
207 | console.error(err);
208 | });
209 | process.on('uncaughtException', (err) => {
210 | // eslint-disable-next-line no-console
211 | console.error(err);
212 | });
213 |
--------------------------------------------------------------------------------
/src/styles/index.js:
--------------------------------------------------------------------------------
1 | import { createGlobalStyle } from 'styled-components';
2 | import reset from 'styled-reset';
3 | import { colors } from 'styles/variables';
4 |
5 | export default createGlobalStyle`
6 | ${reset}
7 | *,
8 | *::before,
9 | *::after {
10 | box-sizing: border-box;
11 | }
12 | html {
13 | min-height: 100%;
14 | text-size-adjust: 100%;
15 | }
16 | body {
17 | position: relative;
18 | overflow-x: hidden;
19 | min-height: 100%;
20 | background-color: ${colors.white};
21 | color: ${colors.superDarkGray};
22 | word-wrap: break-word;
23 | font-size: 14px;
24 | font-family: 'Avenir Next', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
25 | line-height: 1.5;
26 | -webkit-touch-callout: none;
27 | }
28 | a {
29 | color: inherit;
30 | text-decoration: none;
31 | cursor: pointer;
32 | }
33 | img {
34 | vertical-align: middle;
35 | -webkit-touch-callout: none;
36 | }
37 | hr {
38 | margin: 0;
39 | }
40 | span,
41 | i {
42 | display: inline-block;
43 | vertical-align: middle;
44 | }
45 | li {
46 | list-style: none;
47 | }
48 | input,
49 | textarea,
50 | button {
51 | margin: 0;
52 | padding: 0;
53 | outline: none;
54 | border: none;
55 | border-radius: 0;
56 | background-color: inherit;
57 | color: inherit;
58 | vertical-align: middle;
59 | appearance: none;
60 | }
61 | select {
62 | border-radius: 0;
63 | }
64 | input:-webkit-autofill,
65 | textarea:-webkit-autofill {
66 | box-shadow: 0 0 0 1000px white inset;
67 | }
68 | `;
69 |
--------------------------------------------------------------------------------
/src/styles/media.js:
--------------------------------------------------------------------------------
1 | import { devices } from 'styles/variables';
2 |
3 | export default Object.keys(devices).reduce(
4 | (pre, cur) => ({
5 | ...pre,
6 | [cur]: `@media (max-width: ${devices[cur]}px)`,
7 | }),
8 | {}
9 | );
10 |
--------------------------------------------------------------------------------
/src/styles/variables.js:
--------------------------------------------------------------------------------
1 | export const devices = {
2 | desktop: 1024,
3 | tablet: 768,
4 | phone: 480,
5 | };
6 | export const sizes = {
7 | width: {
8 | main: 768,
9 | },
10 | };
11 | export const spaces = {
12 | main: 16,
13 | };
14 | export const colors = {
15 | white: '#fff',
16 | superLightGray: '#eee',
17 | lightGray: '#ccc',
18 | gray: '#999',
19 | darkGray: '#666',
20 | superDarkGray: '#333',
21 | black: '#000',
22 | link: '#a6c0fe',
23 | accent: '#f68084',
24 | };
25 |
--------------------------------------------------------------------------------
/src/sw.js:
--------------------------------------------------------------------------------
1 | importScripts(
2 | 'https://storage.googleapis.com/workbox-cdn/releases/5.1.2/workbox-sw.js'
3 | );
4 |
--------------------------------------------------------------------------------
/src/utils/configureStore.js:
--------------------------------------------------------------------------------
1 | import { createStore, applyMiddleware } from 'redux';
2 | import thunkMiddleware from 'redux-thunk';
3 | import rootReducer from 'reducers';
4 | import { isDevelopment, isClient } from 'config/env';
5 |
6 | const configureStore = (initialState) => {
7 | const middlewares = applyMiddleware(
8 | ...[
9 | thunkMiddleware,
10 | ...(isDevelopment && isClient ? [require('redux-logger').default] : []),
11 | ]
12 | );
13 | const store = createStore(rootReducer, initialState, middlewares);
14 |
15 | return store;
16 | };
17 |
18 | export default configureStore;
19 |
--------------------------------------------------------------------------------
/src/utils/customPush.js:
--------------------------------------------------------------------------------
1 | import { parse } from 'query-string';
2 | import pathToRegexp from 'path-to-regexp';
3 | import { endpoint } from 'config/url';
4 | import routes from 'routes';
5 | import { isJSON } from 'utils/helpers';
6 |
7 | const loadComponent = (route) => {
8 | if (route && route.component && route.component.load) {
9 | return route.component.load();
10 | }
11 | return Promise.resolve({ default: { loadData: null } });
12 | };
13 | const getPageName = (href) => {
14 | return Object.keys(endpoint).find((key) =>
15 | pathToRegexp(endpoint[key]).test(href)
16 | );
17 | };
18 | const getParams = (targetEndpoint, href) => {
19 | const re = pathToRegexp(targetEndpoint);
20 | const endpointInfo = re.exec(href) || [];
21 | const keys = [];
22 |
23 | pathToRegexp(targetEndpoint, keys);
24 |
25 | return keys.reduce(
26 | (pre, cur, index) => ({
27 | [cur.name]: isJSON(endpointInfo[index + 1])
28 | ? JSON.parse(endpointInfo[index + 1])
29 | : endpointInfo[index + 1],
30 | }),
31 | {}
32 | );
33 | };
34 |
35 | const customPush = async (href, push, dispatch, state) => {
36 | const query = href.includes('?')
37 | ? parse(href, { arrayFormat: 'bracket' })
38 | : {};
39 | const pageName = getPageName(href);
40 | const targetEndpoint = pageName ? endpoint[pageName] : '';
41 | const route = routes.find((item) => item.path === targetEndpoint);
42 | const params = getParams(targetEndpoint, href);
43 | const loadedComponent = await loadComponent(route);
44 | const { loadData } = loadedComponent.default;
45 |
46 | if (loadData) {
47 | await loadData({ dispatch, state, params, query, route });
48 | }
49 | push(href);
50 | };
51 |
52 | export default customPush;
53 |
--------------------------------------------------------------------------------
/src/utils/getHtmlString.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { renderToString } from 'react-dom/server';
3 | import serialize from 'serialize-javascript';
4 | import { minify } from 'html-minifier';
5 | import { isDevelopment } from 'config/env';
6 |
7 | const getHtmlString = (
8 | css,
9 | head,
10 | content,
11 | initialState,
12 | scriptElements,
13 | preloadResorceElement
14 | ) => {
15 | const Html = (
16 |
17 |
18 | {head.title.toComponent()}
19 | {head.meta.toComponent()}
20 | {head.link.toComponent()}
21 | {!isDevelopment ? preloadResorceElement : ''}
22 | {!isDevelopment ? css : ''}
23 |
24 |
25 |
29 |
34 | {scriptElements}
35 |
36 |
37 | );
38 | const htmlString = renderToString(Html);
39 | const minifyConfig = {
40 | collapseWhitespace: true,
41 | removeComments: true,
42 | trimCustomFragments: true,
43 | minifyCSS: true,
44 | minifyJS: true,
45 | minifyURLs: true,
46 | };
47 |
48 | return minify(htmlString, minifyConfig);
49 | };
50 |
51 | export default getHtmlString;
52 |
--------------------------------------------------------------------------------
/src/utils/getPreloadResorceElement.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const getPreloadResorceElement = (content, styleTags) => {
4 | const resourcesFromImageTag = (
5 | content.match(/src=".*?"/g) || []
6 | ).map((item) => item.slice(5, -1));
7 | const resourcesFromBackgroundImage = (
8 | styleTags.match(/url\(.*?\)/g) || []
9 | ).map((item) =>
10 | item.includes('"') || item.includes("'")
11 | ? item.slice(5, -2)
12 | : item.slice(4, -1)
13 | );
14 | const totalResources = [
15 | ...resourcesFromImageTag,
16 | ...resourcesFromBackgroundImage,
17 | ];
18 |
19 | return totalResources
20 | .filter((item, index, array) => array.indexOf(item) === index)
21 | .map((resource) => (
22 |
23 | ));
24 | };
25 |
26 | export default getPreloadResorceElement;
27 |
--------------------------------------------------------------------------------
/src/utils/helpers.js:
--------------------------------------------------------------------------------
1 | export const isJSON = (arg) => {
2 | try {
3 | JSON.parse(arg);
4 | return true;
5 | } catch (e) {
6 | return false;
7 | }
8 | };
9 |
--------------------------------------------------------------------------------
/src/utils/link.js:
--------------------------------------------------------------------------------
1 | import { origin, endpoint } from 'config/url';
2 |
3 | class Link {
4 | constructor() {
5 | this.link = {
6 | manifest: '/manifest.json',
7 | canonical: origin,
8 | 'shortcut icon': '/favicon.ico',
9 | 'apple-touch-icon': '',
10 | 'apple-touch-icon-precomposed': '',
11 | };
12 | }
13 |
14 | get(pathname) {
15 | if (pathname !== endpoint.home) {
16 | this.merge({
17 | canonical: origin + pathname,
18 | });
19 | }
20 | return this.parse(this.link);
21 | }
22 |
23 | merge(config = {}) {
24 | this.link = {
25 | ...this.link,
26 | ...config,
27 | };
28 | }
29 |
30 | parse(config) {
31 | return Object.keys(config).map((key) => ({
32 | rel: key,
33 | href: config[key],
34 | }));
35 | }
36 | }
37 |
38 | export default new Link();
39 |
--------------------------------------------------------------------------------
/src/utils/meta.js:
--------------------------------------------------------------------------------
1 | import { colors } from 'styles/variables';
2 | import { endpoint } from 'config/url';
3 |
4 | class Meta {
5 | constructor() {
6 | this.meta = {
7 | viewport:
8 | 'width=device-width,initial-scale=1.0,minimum-scale=1.0,shrink-to-fit=no,viewport-fit=cover',
9 | description: 'All have been introduced React environment',
10 | 'theme-color': colors.accent,
11 | 'format-detection': 'telephone=no',
12 | 'apple-mobile-web-app-capable': 'yes',
13 | 'apple-mobile-web-app-status-bar-style': 'black-translucent',
14 | 'apple-mobile-web-app-title': 'react-ssr-starter',
15 | 'google-site-verification': '',
16 | 'fb:app_id': '',
17 | 'og:title': 'react-ssr-starter',
18 | 'og:type': '',
19 | 'og:description': 'All have been introduced React environment',
20 | 'og:url': '',
21 | 'og:image': '',
22 | 'og:site_name': '',
23 | 'og:locale': 'ja_JP',
24 | 'twitter:card': '',
25 | 'twitter:site': '',
26 | 'twitter:title': 'react-ssr-starter',
27 | 'twitter:description': 'All have been introduced React environment',
28 | 'twitter:image': '',
29 | };
30 | }
31 |
32 | get(pathname) {
33 | if (pathname === endpoint.about) {
34 | this.merge({
35 | description: 'This is about page',
36 | 'og:description': 'This is about page',
37 | 'twitter:description': 'This is about page',
38 | });
39 | }
40 | return this.parse(this.meta);
41 | }
42 |
43 | merge(config = {}) {
44 | this.meta = {
45 | ...this.meta,
46 | ...config,
47 | };
48 | }
49 |
50 | parse(config) {
51 | const defaultParsedMeta = [
52 | { charset: 'utf-8' },
53 | {
54 | 'http-equiv': 'X-UA-Compatible',
55 | content: 'IE=edge,chrome=1',
56 | },
57 | ];
58 | const parsedMeta = Object.keys(config).map((key) => ({
59 | name: key,
60 | [key.includes('og:') || key === 'fb:app_id'
61 | ? 'property'
62 | : 'content']: config[key],
63 | }));
64 |
65 | return [...defaultParsedMeta, ...parsedMeta];
66 | }
67 | }
68 |
69 | export default new Meta();
70 |
--------------------------------------------------------------------------------
/src/utils/path.js:
--------------------------------------------------------------------------------
1 | import path from 'path';
2 |
3 | export const joinPath = (...arg) => {
4 | return path.join(process.cwd(), ...arg);
5 | };
6 |
--------------------------------------------------------------------------------
/tools/jest/setup.js:
--------------------------------------------------------------------------------
1 | import { configure, shallow, render, mount } from 'enzyme';
2 | import Adapter from 'enzyme-adapter-react-16';
3 | import toJson from 'enzyme-to-json';
4 |
5 | configure({ adapter: new Adapter() });
6 |
7 | global.shallow = shallow;
8 | global.render = render;
9 | global.mount = mount;
10 | global.toJson = toJson;
11 |
--------------------------------------------------------------------------------
/tools/storybook/backgroundDecorator.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styled from 'styled-components';
3 | import { colors } from 'styles/variables';
4 |
5 | const BackgroundUI = styled.div`
6 | height: 100vh;
7 | background-color: ${colors.superDarkGray};
8 | `;
9 |
10 | const backgroundDecorater = (story) => {
11 | return {story()};
12 | };
13 |
14 | export default backgroundDecorater;
15 |
--------------------------------------------------------------------------------
/tools/storybook/config.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { configure, addDecorator } from '@storybook/react';
3 | import GlobalStyle from 'styles';
4 |
5 | const req = require.context('../../src/components', true, /\.stories\.js$/);
6 |
7 | const loadStories = () => {
8 | req.keys().forEach((filename) => req(filename));
9 | };
10 |
11 | addDecorator((story) => (
12 | <>
13 |
14 | {story()}
15 | >
16 | ));
17 |
18 | configure(loadStories, module);
19 |
--------------------------------------------------------------------------------
/tools/storybook/routerDecorator.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { MemoryRouter } from 'react-router-dom';
3 |
4 | const routerDecorater = (story) => {
5 | return {story()};
6 | };
7 |
8 | export default routerDecorater;
9 |
--------------------------------------------------------------------------------
/tools/webpack/getClientPlugins.js:
--------------------------------------------------------------------------------
1 | import webpack from 'webpack';
2 | import CleanWebpackPlugin from 'clean-webpack-plugin';
3 | import CopyWebpackPlugin from 'copy-webpack-plugin';
4 | import FriendlyErrorsWebpackPlugin from 'friendly-errors-webpack-plugin';
5 | import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
6 | import ManifestPlugin from 'webpack-manifest-plugin';
7 | import workboxPlugin from 'workbox-webpack-plugin';
8 | import LoadablePlugin from '@loadable/webpack-plugin';
9 | import { env, isDevelopment } from 'config/env';
10 | import { joinPath } from 'utils/path';
11 |
12 | const getPlugins = (isAnalyze) => {
13 | return [
14 | new webpack.EnvironmentPlugin({
15 | NODE_ENV: `${env}`,
16 | ...(process.env.HEROKU_DOMAIN
17 | ? { HEROKU_DOMAIN: process.env.HEROKU_DOMAIN }
18 | : {}),
19 | }),
20 | new webpack.NoEmitOnErrorsPlugin(),
21 | new LoadablePlugin(),
22 | ...(isDevelopment
23 | ? [
24 | new webpack.NamedModulesPlugin(),
25 | new webpack.HotModuleReplacementPlugin(),
26 | new FriendlyErrorsWebpackPlugin(),
27 | ]
28 | : [
29 | new CleanWebpackPlugin([joinPath('dist')], {
30 | root: joinPath(),
31 | exclude: ['server.js'],
32 | }),
33 | new CopyWebpackPlugin([
34 | {
35 | from: './public',
36 | to: joinPath('dist/public'),
37 | ignore: ['.DS_Store'],
38 | },
39 | ]),
40 | new webpack.IgnorePlugin(/redux-logger/),
41 | new webpack.IgnorePlugin(/react-hot-loader/),
42 | new workboxPlugin.GenerateSW({
43 | swDest: joinPath('dist/public/static/javascripts/sw.js'),
44 | clientsClaim: true,
45 | skipWaiting: true,
46 | runtimeCaching: [
47 | {
48 | urlPattern: new RegExp(
49 | '^https://jsonplaceholder.typicode.com/'
50 | ),
51 | handler: 'StaleWhileRevalidate',
52 | },
53 | ],
54 | }),
55 | new ManifestPlugin(),
56 | ]),
57 | ...(isAnalyze ? [new BundleAnalyzerPlugin({ analyzerPort: 8888 })] : []),
58 | ];
59 | };
60 |
61 | export default getPlugins;
62 |
--------------------------------------------------------------------------------
/tools/webpack/getModule.js:
--------------------------------------------------------------------------------
1 | import { joinPath } from 'utils/path';
2 |
3 | const getModule = () => {
4 | return {
5 | rules: [
6 | {
7 | test: /\.js$/,
8 | include: joinPath('src'),
9 | exclude: /node_modules/,
10 | use: {
11 | loader: 'babel-loader',
12 | },
13 | },
14 | {
15 | test: /\.json$/,
16 | include: joinPath('src'),
17 | exclude: /node_modules/,
18 | use: {
19 | loader: 'json-loader',
20 | },
21 | },
22 | ],
23 | };
24 | };
25 |
26 | export default getModule;
27 |
--------------------------------------------------------------------------------
/tools/webpack/getResolve.js:
--------------------------------------------------------------------------------
1 | import { isDevelopment } from 'config/env';
2 |
3 | const getResolve = () => {
4 | return {
5 | extensions: ['.js', '.json'],
6 | ...(!isDevelopment
7 | ? {
8 | alias: {
9 | react: 'preact/compat',
10 | 'react-dom': 'preact/compat',
11 | },
12 | }
13 | : {}),
14 | };
15 | };
16 |
17 | export default getResolve;
18 |
--------------------------------------------------------------------------------
/tools/webpack/getServerPlugins.js:
--------------------------------------------------------------------------------
1 | import webpack from 'webpack';
2 | import CleanWebpackPlugin from 'clean-webpack-plugin';
3 | import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
4 | import LoadablePlugin from '@loadable/webpack-plugin';
5 | import { env } from 'config/env';
6 | import { joinPath } from 'utils/path';
7 |
8 | const getPlugins = (isAnalyze) => {
9 | return [
10 | new webpack.IgnorePlugin(/webpack\.client\.babel/),
11 | new webpack.EnvironmentPlugin({ NODE_ENV: `${env}` }),
12 | new CleanWebpackPlugin([joinPath('dist/server.js')], {
13 | root: joinPath(),
14 | }),
15 | new webpack.optimize.LimitChunkCountPlugin({ maxChunks: 1 }),
16 | new LoadablePlugin(),
17 | ...(isAnalyze ? [new BundleAnalyzerPlugin({ analyzerPort: 8889 })] : []),
18 | ];
19 | };
20 |
21 | export default getPlugins;
22 |
--------------------------------------------------------------------------------
/tools/webpack/webpack.client.babel.js:
--------------------------------------------------------------------------------
1 | import TerserPlugin from 'terser-webpack-plugin';
2 | import { env, isDevelopment } from 'config/env';
3 | import { joinPath } from 'utils/path';
4 | import getModule from 'tools/webpack/getModule';
5 | import getResolve from 'tools/webpack/getResolve';
6 | import getClientPlugins from 'tools/webpack/getClientPlugins';
7 |
8 | export default (webpackEnv) => {
9 | const isAnalyze = webpackEnv.analyze;
10 |
11 | return {
12 | mode: env,
13 | name: 'client',
14 | target: 'web',
15 | cache: isDevelopment,
16 | devtool: isDevelopment
17 | ? 'cheap-module-eval-source-map'
18 | : 'hidden-source-map',
19 | entry: [
20 | ...(isDevelopment
21 | ? [
22 | 'react-hot-loader/patch',
23 | 'webpack-hot-middleware/client?reload=true&quiet=true',
24 | ]
25 | : []),
26 | './src/client.js',
27 | ],
28 | output: {
29 | path: joinPath(!isDevelopment ? 'dist' : '', 'public/static/javascripts'),
30 | filename: `[name]${!isDevelopment ? '.[hash]' : ''}.js`,
31 | publicPath: '/static/javascripts/',
32 | },
33 | plugins: getClientPlugins(isAnalyze),
34 | optimization: {
35 | splitChunks: {
36 | name: 'vendors',
37 | chunks: 'initial',
38 | },
39 | minimize: true,
40 | minimizer: [
41 | new TerserPlugin({
42 | terserOptions: {
43 | ecma: 6,
44 | compress: true,
45 | output: {
46 | comments: false,
47 | beautify: false,
48 | },
49 | },
50 | }),
51 | ],
52 | },
53 | module: getModule(),
54 | resolve: getResolve(),
55 | node: {
56 | fs: 'empty',
57 | vm: 'empty',
58 | net: 'empty',
59 | tls: 'empty',
60 | },
61 | };
62 | };
63 |
--------------------------------------------------------------------------------
/tools/webpack/webpack.server.babel.js:
--------------------------------------------------------------------------------
1 | import nodeExternals from 'webpack-node-externals';
2 | import { env, isDevelopment } from 'config/env';
3 | import { joinPath } from 'utils/path';
4 | import getModule from 'tools/webpack/getModule';
5 | import getResolve from 'tools/webpack/getResolve';
6 | import getServerPlugins from 'tools/webpack/getServerPlugins';
7 |
8 | export default () => {
9 | return {
10 | mode: env,
11 | name: 'server',
12 | target: 'node',
13 | devtool: isDevelopment
14 | ? 'cheap-module-eval-source-map'
15 | : 'hidden-source-map',
16 | entry: ['./src/server.js'],
17 | output: {
18 | path: joinPath('dist'),
19 | filename: 'server.js',
20 | libraryTarget: 'commonjs2',
21 | },
22 | plugins: getServerPlugins(),
23 | module: getModule(),
24 | resolve: getResolve(),
25 | node: {
26 | console: false,
27 | global: false,
28 | process: false,
29 | Buffer: false,
30 | __filename: true,
31 | __dirname: true,
32 | },
33 | externals: [
34 | '@loadable/component',
35 | nodeExternals({
36 | whitelist: [/\.(?!(?:json)$).{1,5}$/i],
37 | }),
38 | ],
39 | };
40 | };
41 |
--------------------------------------------------------------------------------