├── .babelrc
├── .dockerignore
├── .eslintrc
├── .gitignore
├── .storybook
├── config.js
└── webpack.config.js
├── .stylelintrc
├── .travis.yml
├── Dockerfile
├── LICENSE.md
├── README.md
├── app.json
├── app
├── client
│ ├── index.js
│ └── root.js
├── common
│ ├── WithStylesContext.js
│ ├── components
│ │ ├── .gitkeep
│ │ ├── Button
│ │ │ ├── index.js
│ │ │ ├── index.story.js
│ │ │ └── styles.scss
│ │ ├── ErrorMessages
│ │ │ └── index.js
│ │ ├── Form
│ │ │ ├── FormRow
│ │ │ │ ├── index.js
│ │ │ │ └── styles.scss
│ │ │ ├── index.js
│ │ │ ├── index.story.js
│ │ │ └── styles.scss
│ │ ├── FormField
│ │ │ ├── index.js
│ │ │ └── styles.scss
│ │ ├── Icon
│ │ │ ├── icons.font.js
│ │ │ ├── icons
│ │ │ │ ├── add.svg
│ │ │ │ ├── arrow-down.svg
│ │ │ │ ├── arrow-left-large.svg
│ │ │ │ ├── arrow-left.svg
│ │ │ │ ├── arrow-right.svg
│ │ │ │ ├── arrows-expand.svg
│ │ │ │ ├── arrows-reduce.svg
│ │ │ │ ├── check-right.svg
│ │ │ │ ├── doc.svg
│ │ │ │ ├── eye.svg
│ │ │ │ └── trash.svg
│ │ │ ├── index.js
│ │ │ ├── index.story.js
│ │ │ └── templates
│ │ │ │ └── css.hbs
│ │ ├── Poster
│ │ │ ├── index.js
│ │ │ ├── index.story.js
│ │ │ └── styles.scss
│ │ ├── TextInput
│ │ │ ├── index.js
│ │ │ ├── index.story.js
│ │ │ └── styles.scss
│ │ ├── TextareaInput
│ │ │ ├── index.js
│ │ │ ├── index.story.js
│ │ │ └── styles.scss
│ │ └── _Component_
│ │ │ ├── index.js
│ │ │ ├── index.story.js
│ │ │ └── styles.scss
│ ├── config.js
│ ├── containers
│ │ ├── blocks
│ │ │ ├── .gitkeep
│ │ │ └── MovieCard
│ │ │ │ ├── index.js
│ │ │ │ ├── index.story.js
│ │ │ │ └── styles.scss
│ │ ├── forms
│ │ │ ├── .gitkeep
│ │ │ └── MovieForm
│ │ │ │ └── index.js
│ │ ├── layouts
│ │ │ ├── .gitkeep
│ │ │ ├── App
│ │ │ │ ├── index.js
│ │ │ │ └── styles.scss
│ │ │ └── Main
│ │ │ │ ├── index.js
│ │ │ │ └── styles.scss
│ │ └── pages
│ │ │ ├── .gitkeep
│ │ │ ├── MoviesCreatePage
│ │ │ ├── index.js
│ │ │ └── styles.scss
│ │ │ ├── MoviesDetailsPage
│ │ │ ├── index.js
│ │ │ └── styles.scss
│ │ │ ├── MoviesListPage
│ │ │ ├── index.js
│ │ │ └── styles.scss
│ │ │ └── NotFoundPage
│ │ │ └── index.js
│ ├── helpers
│ │ ├── .gitkeep
│ │ ├── tests
│ │ │ └── mount.js
│ │ ├── url.js
│ │ ├── url.test.js
│ │ └── validate.js
│ ├── locales
│ │ ├── .gitkeep
│ │ ├── en.po
│ │ ├── ru.po
│ │ ├── source.pot
│ │ └── uk.po
│ ├── redux
│ │ ├── .gitkeep
│ │ ├── api.js
│ │ ├── data
│ │ │ ├── index.js
│ │ │ └── movies.js
│ │ ├── index.js
│ │ ├── language.js
│ │ └── ui
│ │ │ └── loading.js
│ ├── routes
│ │ ├── .gitkeep
│ │ └── index.js
│ ├── schemas
│ │ ├── .gitkeep
│ │ └── index.js
│ ├── services
│ │ ├── .gitkeep
│ │ ├── i18next.js
│ │ └── validations.js
│ ├── store
│ │ ├── .gitkeep
│ │ └── index.js
│ └── styles
│ │ └── variables.scss
└── server
│ ├── __dev.js
│ ├── api
│ └── index.js
│ ├── page.js
│ ├── server.js
│ ├── sitemap.js
│ └── views
│ └── index.ejs
├── bin
├── build.sh
├── ci
│ └── push.sh
├── release.sh
├── start.sh
└── version-increment.sh
├── cypress.json
├── cypress
├── fixtures
│ └── example.json
├── integration
│ ├── creating_new_post.js
│ └── movies_list.js
├── plugins
│ └── index.js
└── support
│ ├── commands.js
│ └── index.js
├── docs
├── .gitkeep
└── images
│ └── atom-eslint-autofix.png
├── package.json
├── postcss.config.js
├── public
└── .gitkeep
├── webpack.config.js
├── webpack.server.js
├── webpack
└── parts.js
└── yarn.lock
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | ["es2015", { "modules": false }],
4 | "react",
5 | "stage-0",
6 | ],
7 | "plugins": [
8 | "transform-runtime",
9 | "transform-decorators-legacy",
10 | [
11 | "module-resolver",
12 | {
13 | "alias": {
14 | "tests": "./tests",
15 | "withStyles": "isomorphic-style-loader/lib/withStyles",
16 | "public": "./public",
17 | "@": "./app/common"
18 | }
19 | }
20 | ],
21 | ],
22 | "env": {
23 | "development": {
24 | "plugins": [
25 | "react-hot-loader/babel"
26 | ]
27 | },
28 | "test": {
29 | "presets": [
30 | "es2015",
31 | "react",
32 | "stage-0",
33 | ]
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | confs
3 | bin
4 | scripts
5 | tests
6 | .md
7 | *.log
8 | .git
9 | .dockerignore
10 | Dockerfile
11 | docs/
12 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "airbnb",
3 | "parser": "babel-eslint",
4 | "env": {
5 | "browser": true,
6 | "node": true,
7 | "cypress/globals": true
8 | },
9 | "globals": {
10 | "__DEV__": true,
11 | "__CLIENT__": true,
12 | "__REDUX_STATE__": true
13 | },
14 | "plugins": [
15 | "chai-expect",
16 | "cypress"
17 | ],
18 | "rules": {
19 | "no-nested-ternary": 0,
20 | "react/no-unused-prop-types": 0,
21 | "react/forbid-prop-types": 0,
22 | "new-cap": 0,
23 | "camelcase": 0,
24 | "no-bitwise": 0,
25 | "no-shadow": 0,
26 | "no-inner-declarations": 0,
27 | "no-unused-expressions": 0,
28 | "no-underscore-dangle": 0,
29 | "no-use-before-define": 0,
30 | "class-methods-use-this": 0,
31 | "import/prefer-default-export": 0,
32 | "import/no-extraneous-dependencies": 0,
33 | "react/jsx-filename-extension": 0,
34 | "react/prop-types": 0,
35 | "react/prefer-stateless-function": 0,
36 | "chai-expect/missing-assertion": 2,
37 | "chai-expect/terminating-properties": 1,
38 | "jsx-a11y/label-has-for": 0,
39 | "jsx-a11y/no-static-element-interactions": 0
40 | },
41 | "parserOptions":{
42 | "ecmaFeatures": {
43 | "classes": true,
44 | "experimentalObjectRestSpread": true
45 | }
46 | },
47 | "settings": {
48 | "import/resolver": {
49 | "babel-module": {}
50 | }
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .tmp
3 | .idea
4 | .DS_Store
5 | *.log
6 | static
7 | .env
8 | coverage
9 | lcov.info
10 | tests_output
11 | !Icon
12 | !icon
13 | browserstack.err
14 | *.mo
15 | public/sitemap*.xml
16 | logs
17 | stats.json
18 | cypress/screenshots
19 |
--------------------------------------------------------------------------------
/.storybook/config.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { configure, addDecorator } from '@storybook/react'
3 | import { Provider } from 'react-redux'
4 | import { noop } from 'lodash'
5 | import { Router } from 'react-router'
6 | import createMemoryHistory from 'history/lib/createMemoryHistory';
7 | import { I18nextProvider } from 'react-i18next';
8 | import CookieDough from 'cookie-dough'
9 |
10 | import AppLayout from '@/containers/layouts/App'
11 | import { configureStore } from '@/store'
12 | import i18n from '@/services/i18next';
13 | import WithStylesContext from '@/WithStylesContext';
14 |
15 | const requireContext = require.context('../app', true, /\.story\.js$/)
16 | const loadStories = () => {
17 | // https://webpack.github.io/docs/context.html
18 | requireContext.keys().forEach(requireContext)
19 | }
20 |
21 | const store = configureStore({ history: createMemoryHistory(), cookies: CookieDough(), i18n })
22 | addDecorator(story => (
23 |
24 |
25 |
26 |
27 | {story()}
28 |
29 |
30 |
31 |
32 | ))
33 |
34 | configure(loadStories, module)
35 |
--------------------------------------------------------------------------------
/.storybook/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const webpack = require('webpack');
3 | const webpackMerge = require('webpack-merge')
4 | const parts = require('../webpack/parts')
5 |
6 | module.exports = webpackMerge(
7 | {
8 | plugins: [
9 | new webpack.DefinePlugin({
10 | 'process.env': {
11 | NODE_ENV: JSON.stringify(process.env.NODE_ENV || 'development'),
12 | },
13 | __CLIENT__: true,
14 | }),
15 | ],
16 | },
17 | parts.setupJs(),
18 | parts.setupFont(),
19 | parts.setupImages(),
20 | parts.setupJson(),
21 | parts.setupI18n(),
22 | parts.setupCss(),
23 | parts.setupFontGen(),
24 | )
25 |
--------------------------------------------------------------------------------
/.stylelintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "stylelint-config-standard",
3 | "rules": {
4 | "selector-pseudo-class-no-unknown": [true, {
5 | ignorePseudoClasses: [
6 | "global",
7 | "local"
8 | ],
9 | }]
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | services:
3 | - docker
4 | cache:
5 | directories:
6 | - "~/.nvm"
7 | - node_modules
8 | - ~/.npm
9 | - ~/.cache
10 | env:
11 | global:
12 | - RELEASE_BRANCH="master"
13 | - DOCKER_HUB_ACCOUNT="frontband"
14 | - MAIN_BRANCHES="master develop"
15 | - secure: X3VGjTmC+XMv1cockfzaYLcVPBX9jgbFCwXSvzlxjQ8M1d9dYHgQO3JHaprdWTiELSEK/rjf3Tw8LmT9l0EkX8nh/mU9c1aTEGqgr6w2lTYt3P03olDKA5spDivIFoy0KjtYJJcrBuYl7q9OdTmpJl3JPsm9PSsJTdaLmd9KU/rmdeJMXVL/S7QvXDNNFd82k12j2sO4EeeaSMOfid4TvmrfsNh/zDHk0TU4WZTTCBTawxgGymfw1Xnp6tl8UhCOFLMLatb9gaeI0cAWiyPM03FZQbEbQjMU79ifBeVZFtP/PCbF+w2m9GZc8/G1CzwSn39P1TpB7uEzCqMXYKfrJI2ROPK0LbaHmHRrIBPbZDtjzsAWEDCQqk3oTJjWSCXcaeRG90ByP2qOsuxzfQDGx0prTaToRAsjfL0frSBdHS8G0ebtxCD6x68DH0iMFkUH4sUAXpnl23RVblwR9/xSv5g8Qgea5xDIQEXDchLJtcHCwtVvH6xD6gSwIpXmdLZTWRlqGaaDoxO2nXCfZ7eWAeu5nM8Y8S/CfHcqYYPKRqIi2M95DmhUlofXY/7KTjK/tBW1dGUx1g3CYffZklrdTU9qlSWryeYWiZQj5tNPYlcDlPHcTGune8p4zcJg3dVOMKZVFI6CgO8v6jj/t2kb41iUFKyzmuYG1LT8eENQrJg=
16 | node_js:
17 | - 7.5.0
18 | install:
19 | - yarn install --no-progress --frozen-lockfile
20 | before_install:
21 | - chmod -R +x bin
22 | script:
23 | - yarn test
24 | - yarn lint
25 | - "./bin/version-increment.sh"
26 | - "./bin/build.sh"
27 | - "./bin/start.sh"
28 | - sleep 5
29 | - docker ps
30 | - RUNNING_CONTAINERS=`docker ps | wc -l`; if [ "${RUNNING_CONTAINERS//[[:space:]]/}"
31 | == "1" ]; then echo "[E] Container is not started\!"; docker logs react-boilerplate
32 | --details --since 5h; exit 1; fi;
33 | - yarn test:acceptance
34 | - ARGOS_COMMIT=$TRAVIS_COMMIT ARGOS_BRANCH=$TRAVIS_BRANCH yarn argos upload cypress/screenshots --token $ARGOS_TOKEN || true
35 | - sleep 5
36 | after_success:
37 | - "./bin/ci/push.sh"
38 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:9.3.0-alpine
2 |
3 | EXPOSE 8080
4 |
5 | ENV PORT 8080
6 | ENV NODE_ENV production
7 |
8 | RUN apk add --update \
9 | python
10 |
11 | RUN npm i -g pm2 --quiet
12 |
13 | COPY package.json /tmp/package.json
14 | COPY yarn.lock /tmp/yarn.lock
15 |
16 | RUN yarn --version && node --version && npm --version
17 |
18 | RUN cd /tmp && ls -la && yarn install --no-progress --frozen-lockfile || { exit 1; } && mkdir -p /opt/app && cp -a /tmp/node_modules /opt/app/
19 |
20 | WORKDIR /opt/app
21 |
22 | COPY . /opt/app
23 |
24 | RUN yarn build
25 |
26 | RUN rm -rf ./app/client \
27 | rm -rf ./app/common \
28 | rm -rf ./node_modules/webpack
29 |
30 | # Clear deps and caches
31 | RUN apk --purge del python && rm -rf /var/cache/apk/*
32 |
33 | CMD pm2 start --log-type json --no-daemon static/server.js
34 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Front.Band
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # React Boilerplate
2 |
3 | Example project. We are using it as a start point for out applications or as a base for an education of our developers.
4 |
5 | ## Commands
6 |
7 | | Command | Description |
8 | | - | - |
9 | | `yarn dev` | Run dev server |
10 | | `yarn production` | Run production server |
11 | | `yarn lint` | Check code with Eslint and Stylelint |
12 | | `yarn build` | Build production |
13 | | `yarn stats` | Run webpack statistic dashboard to analyze bundle size` |
14 | | `yarn locales:extract` | Extract locales from the app into `.pot` file |
15 | | `yarn storybook` | Run storybook |
16 | | `yarn test:acceptance` | Run acceptance tests |
17 |
18 | ## Frontend Architecture
19 |
20 | ### Project structure
21 |
22 | - `app` - our application files. React JS app is here
23 | - `.storybook` - storybook configuration files.
24 | - `bin` - CI automation scripts
25 | - `cypress` - acceptance tests
26 | - `docs` - docs related documents, assets
27 | - `public` - public static data, like favicon, robot.txt, that is not using in react js app and that doesn't have to go thought webpack process
28 | - `webpack` - webpack configuration parts
29 |
30 | #### Application structure
31 |
32 | Inside `app` folder:
33 |
34 | - `client` - client's entrypoint
35 | - `server` - server's entrypoint
36 | - `common` - code, that is shared between client and server
37 | - `common/components` - generic components. Core UI of our application. Elements, that can be publish and reused in other projects.
38 | - `common/containers/blocks` - not generic components. specific for this project. API driven and can't be re-used in other projects
39 | - `common/containers/forms` - application's forms
40 | - `common/containers/layouts` - layouts
41 | - `common/containers/pages` - pages
42 | - `common/helpers` - helpers.
43 | - `common/locales` - localization files and template
44 | - `common/redux` - redux modules. reducers and actions are here
45 | - `common/routes` - routes definitions.
46 | - `common/schemas` - normalizr schemas.
47 | - `common/services` - configuration of 3rd party modules and services
48 | - `common/store` - configuration of redux store
49 | - `common/styles` - shared styles, like variables
50 |
51 | ### Code style
52 |
53 | #### Eslint
54 |
55 | We're using eslint to keep js and jsx code style consistent. Check that your IDE is using Eslint file from this repo. Anyway, pre-commit hook is checking lint with our internal installed eslint version. So if your IDE is not showing you the errors, but you have them in pre-commit hook, re-check IDE :D
56 |
57 | ##### Atom
58 | Add plugin [linter-eslint](https://atom.io/packages/linter-eslint). Go to the plugin's configuration and enable option **Fix errors on save**
59 |
60 | 
61 |
62 | #### Stylelint
63 |
64 | Stylelint is using to control the codestyle in style files. Configure your IDE to use config from this repo.
65 |
66 | ### Git flow
67 |
68 | - **Stable branch** - `master`
69 | - Don't push directly to the stable branch. Use PRs instead
70 |
71 | **Workflow:**
72 |
73 | 1. Start a ticket with a new branch
74 | 2. Write code
75 | 3. Create Pull Request
76 | 4. Get an approve from one of your coworkers
77 | 5. Merge PR's branch with the stable branch
78 |
79 | #### Name of the branches
80 |
81 | We are not following some strict rule on this stage of branch naming. So we have a single rule for the branch names:
82 | 1. Make you branch names meaningful.
83 |
84 | Bad example
85 | ```
86 | fix-1
87 | redesign
88 | ```
89 |
90 | Good example
91 | ```
92 | fix-signals-table
93 | new-user-profile-page
94 | ```
95 |
96 | ##### JIRA tickets
97 |
98 | If you are using JIRA as a task manager, follow this naming convention
99 |
100 | ```
101 | [type of ticket]/[number of branch]-[short-title]
102 |
103 | feature/FRB-123-change-titles
104 | fix/FRB-431-retina-images
105 | ```
106 |
107 | ### Components
108 |
109 | We are creating React application with component-approach. This means, what we try to decompose our UI into re-usable parts.
110 |
111 | Try to make components PURE when it's possible. Avoid using redux or inner state for components in `app/common/components`.
112 |
113 | Base component contains next files:
114 |
115 | - `index.js` - base component
116 | - `styles.scss` - styles file
117 | - `index.story.js` - storybook file
118 |
119 | See the [example component](./app/common/components/_Component_).
120 |
121 | #### Recompose
122 |
123 | Use recompose to create logic layout of your components. recompose allow us to split logic into multiple re-usable parts.
124 |
125 | #### Storybook
126 |
127 | Storybook is using as a UI library. We are using it as documentation for our UI. The goals are:
128 |
129 | - help teammates to find the right component
130 | - help to understand how to use the component
131 | - avoid duplications
132 |
133 | **The rule is**: write a story for all generic pure components and blocks and show all the existing variations.
134 |
135 | Help your teammates understand from story how to use your component. Not just write the story of itself. Think about your colleagues.
136 |
137 | #### Styling
138 |
139 | Shortly about our styles:
140 | - CSS modules
141 | - PostCss with SCSS like style
142 |
143 | We're using scoped styles, so you don't need to use BEM or other methodology to avoid conflicts in the styles.
144 | In BEM terminology, you don't have to use elements. Use only block and modificators.
145 |
146 | **If you feel that you also need an element - think, probably you have to extract a new component from this template.**
147 |
148 | Bad example
149 |
150 | ```scss
151 | .page {
152 | &__title {}
153 | &__content {
154 | &_active {}
155 | }
156 | }
157 | ```
158 |
159 | Good example
160 |
161 | ```scss
162 | .root {} // root element
163 | .title {}
164 | .content {
165 | &.isActive {}
166 | }
167 | ```
168 |
169 | Use `is` prefix for the modificators.
170 |
171 | ##### Injecting styles to component
172 |
173 | We are using `isomorphic style loader` to calculate critical CSS path. Use withStyles HOC to connect component and style. Use `withStyles` alias for import.
174 |
175 | For example
176 |
177 | ```js
178 | import React from 'react';
179 | import PropTypes from 'prop-types';
180 | import withStyles from 'withStyles';
181 | import { compose } from 'recompose';
182 | import styles from './styles.scss';
183 |
184 | const Poster = ({ src, title }) => (
185 |
186 | );
187 |
188 | Poster.propTypes = {
189 | children: PropTypes.node,
190 | };
191 |
192 | export default compose(
193 | withStyles(styles)
194 | )(Poster);
195 | ```
196 |
197 | #### Prop names
198 |
199 | **Make names meaningful.**
200 |
201 | ```html
202 |
203 | ```
204 |
205 | Good example
206 | ```html
207 |
208 | ```
209 |
210 | **Make property for group of variants, not only of one specific case**
211 |
212 | Bad example
213 | ```html
214 |
215 |
216 |
217 | ```
218 |
219 | Good example
220 | ```html
221 |
222 |
223 |
224 | ```
225 |
226 | **Make property short**
227 |
228 | Bad example
229 |
230 | ```html
231 |
232 | ```
233 |
234 | Good example
235 |
236 | ```html
237 |
238 | ```
239 |
240 | **Use `on` prefix for callbacks**
241 |
242 | Bad example
243 | ```html
244 |
245 | ```
246 |
247 | Good example
248 | ```html
249 |
250 | ```
251 |
252 | **`true` if present**
253 |
254 | ` ` equals ` `
255 |
256 | **`false` if missed**
257 |
258 | ` ` equals ` `
259 |
260 |
261 | #### Usage
262 |
263 | Use component fully as a block. Don't make the components styles configurable outside. It has to have the deterministic number of possible variants.
264 |
265 | Bad example
266 |
267 | ```html
268 |
269 | ```
270 |
271 | This is a chore. Passing classname or style from parent component can solve a problem easily in short terms, but in the future, you will be updating your components and you will not remember about this modification. so you will not test it. and it would be a bug.
272 |
273 | Good example
274 |
275 | ```html
276 |
277 |
278 |
279 | ```
280 |
281 | ### Redux
282 |
283 | We are using redux as our global state manager in the app. Our base store structure is:
284 |
285 | - `data` - data redux modules.
286 | - `form` - connect redux-form
287 | - `routing` - connect react-router-redux
288 | - `ui` - reducers of UI components
289 |
290 | Actions and reducers are stored in the same file. We are using [`redux-actions`](npmjs.com/package/redux-actions) as a single way to create an action. See [example](./app/common/redux/ui/loading.js).
291 |
292 | #### Selectors
293 |
294 | Selectors are stored in [redux/index.js](./app/common/redux/index.js) file. Basic set of data selectors contains next methods:
295 |
296 | ```javascript
297 | export const getMovie = (state, id) => denormalize(id, schemas.movie, state.data);
298 |
299 | export const getMovies = (state, ids) => ids.map(id => getMovie(state, id));
300 |
301 | export const getAllMovies = state => getMovies(state, Object.keys(state.data.movies));
302 | ```
303 |
304 | ### Data
305 |
306 | #### API
307 |
308 | We are using [redux-api-middleware](npmjs.com/package/redux-api-middleware) to make the API calls. It's use universal fetch internally.
309 |
310 | Use method [`invoke`](./app/common/redux/api.js) to create API request actions.
311 |
312 | #### Caching, Normalization
313 |
314 | We use redux as a caching layer. Redux store contains data object, that has next structure
315 |
316 | example
317 | ```js
318 | data: {
319 | movies: {
320 | 1: {
321 | id: 1,
322 | name: 'The Shawshank Redemption'
323 | },
324 | 2: {
325 | id: 2,
326 | name: 'The Godfather'
327 | },
328 | },
329 | directors: {
330 | 1: {
331 | id: 1,
332 | name: 'Francis Ford Coppola'
333 | }
334 | }
335 | }
336 | ```
337 |
338 | So all the data entities are grouped by collection and is stored in map, there key is `id` of the entity and `value` is entity itself.
339 |
340 | This structure allows us to easily find entity in store by id.
341 |
342 | Each API request contains normalization of the response. [Example](./app/common/redux/data/movies.js).
343 |
344 | To normalize data we are using [`normalizr` package](npmjs.com/package/normalizr). Normalization schemas are store in [`schemas` folder](./app/common/schemas).
345 |
346 | See an example of fetching and normalization of the data [here](./app/common/redux/data/movies.js).
347 |
348 | #### Data usage on the pages
349 |
350 | Use `connect` and `selectors` to get the store data on the page. Don't store the data directly on the page. Store identifier and get the value by id, with selector instead.
351 |
352 | For example,
353 |
354 | ```js
355 | export default compose(
356 | withStyles(styles),
357 | translate(),
358 | withRouter,
359 | provideHooks({
360 | fetch: ({ dispatch, params, setProps }) => dispatch(fetchMovie(params.id)).then((response) => {
361 | setProps({
362 | movieId: response.payload.result,
363 | });
364 | }),
365 | }),
366 | connect((state, ownProps) => ({
367 | movie: getMovie(state, ownProps.movieId),
368 | })),
369 | )(MoviesDetailsPage);
370 |
371 | ```
372 | After the fetch of the data we don't store the whole object, but just the id of the movie and then we get whole object with getMovie selector. It allow us to be sure, that redux store is only one source of data in our applications. It helps to avoid many bugs.
373 |
374 | ### Routing
375 |
376 | 1. You have to be able to load page information based only on the URL params.
377 | 2. Use the same URL as a main API endpoint of the page.
378 |
379 | ```
380 | Page URL: /users
381 | API request: get:/users
382 |
383 | Page URL: /users/:id
384 | API request: get:/users/:id
385 |
386 | Page URL: /users/new
387 | API request: post:/users
388 | ```
389 |
390 | 3. If you need to save the state of the page - use the URL query params. `e.g /users?page=1, /users?q=John Doe`
391 | 4. Make URLs meaningful
392 |
393 |
394 | #### Fetching hooks
395 |
396 | Pages and layouts are 2 places, there data can be fetched. WE don't fetch the data into blocks. Only in the containers, that are used as a component in Routes, so they are URL driven.
397 |
398 | To fetch the data on the page we are using redial + react-router-redial
399 |
400 | | hook | beforeTransition | afterTransition | client | server |
401 | | - | - | - | - | - |
402 | | `fetch` | + | - | + | + |
403 | | `defer` | - | + | + | - |
404 | | `server` | - | + | - | + |
405 | | `done` | - | + | + | + |
406 |
407 | We're additional passing `dispatch` and `getState` methods to the hooks, so you can access the store and dispatch an action.
408 |
409 | ### Authorization
410 |
411 | TODO
412 |
413 | ### Testing
414 |
415 | #### Acceptance testing
416 |
417 | Cypress.io is used for E2E testing.
418 |
419 | To run test, execute next command. You need server started and installed Chrome browser on your machine.
420 |
421 | ```sh
422 | yarn test:acceptance
423 | ```
424 |
425 | Tests are storing in [cypress folder](./cypress).
426 |
427 | #### Unit testing
428 |
429 | TODO. add jest to boilerplate
430 |
431 | #### Visual regression testing
432 |
433 | TODO. add argos CI to boilerplate
434 |
435 | ### Localization
436 |
437 | We are using `gettext` standard for our basic localization process.
438 |
439 | #### Extract localization template
440 |
441 | ```bash
442 | yarn locales:extract
443 | ```
444 |
445 | This command will extract string for localization into `locales/source.pot` file.
446 |
447 | Use **Poedit** to create lang files based on .pot file.
448 |
449 | Localization is configured in `common/services/i18next.js`. Check the code to see how to import `.po` file with new language to the app.
450 |
451 | ### Forms
452 |
453 | We are using Redux-form as a single way to work with the forms.
454 |
455 | #### Validation
456 |
457 | We use per-form validation. For validation we are using [react-nebo15-validate](https://github.com/Nebo15/react-nebo15-validate/)
458 |
459 | [Example](./app/common/containers/forms/MovieForm/index.js)
460 |
461 | ##### Add custom validation message
462 |
463 | To add custom validation message, add it to ErrorMessages components. This component is used in Field components to show validation messages.
464 |
465 | See the [existing customization](./app/common/components/ErrorMessages/index.js). Use this common in all places, when you need to display the error message based on error object.
466 |
467 | ### 3rd party services
468 |
469 | Configuration of the 3rd party services is stored in [`common/services`](./app/common/services). See the example of i18next and validations configurations. They are separately connected to client and server entrypoints.
470 |
471 | ### SSR
472 |
473 | ### Node JS API
474 |
475 | ### Configuration
476 |
477 | ### Release & Deployment
478 |
479 | ### Build tools
480 |
481 | ## License
482 |
483 | See [LICENSE.md](LICENSE.md).
484 |
--------------------------------------------------------------------------------
/app.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-boilerplate",
3 | "scripts": {
4 | },
5 | "env": {
6 | "PREBUILD": {
7 | "description": "RUN postinstall script. MUST be `true`",
8 | "required": true,
9 | "value": "true"
10 | },
11 | "SITEMAP_HOSTNAME": {
12 | "description": "SITEMAP_HOSTNAME will be used as hostname in sitemap URLs",
13 | "required": false,
14 | "value": ""
15 | },
16 | "LANG_COOKIE_NAME": {
17 | "description": "Name of the cookie, where storing language variable",
18 | "value": "lang"
19 | }
20 | },
21 | "formation": {
22 | "web": {
23 | "quantity": 1
24 | }
25 | },
26 | "addons": [
27 |
28 | ],
29 | "buildpacks": [
30 | {
31 | "url": "heroku/nodejs"
32 | }
33 | ]
34 | }
35 |
--------------------------------------------------------------------------------
/app/client/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 |
4 | import { AppContainer } from 'react-hot-loader';
5 |
6 | import CookieDough from 'cookie-dough';
7 |
8 | import { browserHistory, match } from 'react-router';
9 | import { syncHistoryWithStore } from 'react-router-redux';
10 |
11 | import i18n from '@/services/i18next';
12 | import '@/services/validations';
13 |
14 | import { configureStore } from '@/store';
15 | import { configureRoutes } from '@/routes';
16 |
17 | import Root from './root';
18 |
19 | let reduxState = {};
20 |
21 | if (window.__REDUX_STATE__) { // eslint-disable-line no-underscore-dangle
22 | try {
23 | reduxState = JSON.parse(unescape(__REDUX_STATE__));
24 | } catch (e) {} // eslint-disable-line
25 | }
26 |
27 | const store = configureStore({
28 | history: browserHistory,
29 | cookies: CookieDough(),
30 | i18n,
31 | }, reduxState);
32 |
33 | const { dispatch, getState } = store;
34 | const history = syncHistoryWithStore(browserHistory, store);
35 | const trackPage = (route) => {}; // eslint-disable-line
36 | const routes = configureRoutes({ store });
37 |
38 | const locals = {
39 | // Allow lifecycle hooks to dispatch Redux actions:
40 | dispatch,
41 | getState,
42 | };
43 |
44 | const render = (Component, { renderProps }) => {
45 | ReactDOM.render((
46 |
47 |
57 |
58 | ), document.getElementById('root'));
59 | };
60 |
61 | let lastRenderProps = null;
62 | /* eslint-disable no-underscore-dangle */
63 | match({ history, routes }, (error, redirectLocation, renderProps) => {
64 | lastRenderProps = renderProps;
65 | render(Root, { renderProps });
66 | });
67 |
68 |
69 | // enable hot reloading, will be stripped in production
70 | if (process.env.NODE_ENV !== 'production' && module.hot) {
71 | module.hot.accept([
72 | './root',
73 | '../common/routes',
74 | '../common/services/i18next',
75 | ], () => {
76 | render(Root, lastRenderProps);
77 | });
78 |
79 | module.hot.accept('../common/redux', () => {
80 | const nextRootReducer = require('../common/redux').default; // eslint-disable-line
81 | store.replaceReducer(nextRootReducer);
82 | });
83 | }
84 |
--------------------------------------------------------------------------------
/app/client/root.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Provider } from 'react-redux';
3 | import { noop } from 'lodash';
4 |
5 | import { Router, applyRouterMiddleware } from 'react-router';
6 | import { useRedial } from 'react-router-redial';
7 |
8 | import { I18nextProvider } from 'react-i18next';
9 |
10 | import { showLoading, hideLoading } from '@/redux/ui/loading';
11 | import WithStylesContext from '@/WithStylesContext';
12 |
13 | const trackPage = (route) => {}; // eslint-disable-line
14 |
15 | export default class RootComponent extends React.Component {
16 | constructor(props) {
17 | super(props);
18 | // History and routes in Router can't be replaced
19 | this.routes = props.routes;
20 | this.history = props.history;
21 |
22 | // Store in Provider can't be replaced
23 | this.store = props.store;
24 | }
25 | render() {
26 | const {
27 | renderProps,
28 | locals,
29 | i18n,
30 | } = this.props;
31 |
32 | const {
33 | history,
34 | routes,
35 | store,
36 | } = this;
37 |
38 | return (
39 |
40 |
41 |
42 | Loading...
),
53 | onStarted: () => {
54 | store.dispatch(showLoading());
55 | },
56 | onCompleted: (transition) => {
57 | store.dispatch([
58 | hideLoading(),
59 | ]);
60 | if (transition === 'beforeTransition') {
61 | window.scrollTo(0, 0);
62 | }
63 | },
64 | onAborted: () => {
65 | store.dispatch(hideLoading());
66 | },
67 | onError: () => {
68 | store.dispatch(hideLoading());
69 | },
70 | })
71 | )}
72 | />
73 |
74 |
75 |
76 | );
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/app/common/WithStylesContext.js:
--------------------------------------------------------------------------------
1 |
2 | import { Component, Children } from 'react';
3 | import PropTypes from 'prop-types';
4 |
5 | export default class WithStylesContext extends Component {
6 | static propTypes = {
7 | children: PropTypes.element.isRequired,
8 | onInsertCss: PropTypes.func.isRequired,
9 | };
10 |
11 | static childContextTypes = {
12 | insertCss: PropTypes.func.isRequired,
13 | };
14 |
15 | getChildContext() {
16 | return { insertCss: this.props.onInsertCss };
17 | }
18 |
19 | render() {
20 | return Children.only(this.props.children);
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/app/common/components/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FrontBand/react-boilerplate/f76ddc9b93d804180e908bb0ff47825108f05da5/app/common/components/.gitkeep
--------------------------------------------------------------------------------
/app/common/components/Button/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import { Link } from 'react-router';
4 | import classnames from 'classnames';
5 | import withStyles from 'withStyles';
6 | import { compose } from 'recompose';
7 | import styles from './styles.scss';
8 |
9 | const Button = ({ block, to, ...rest }) => (
10 | React.createElement(
11 | to ? Link : 'button',
12 | {
13 | className: classnames(styles.root, block && styles.isBlock),
14 | to,
15 | ...rest,
16 | }
17 | )
18 | );
19 |
20 | Button.propTypes = {
21 | block: PropTypes.bool,
22 | };
23 |
24 | export default compose(
25 | withStyles(styles)
26 | )(Button);
27 |
--------------------------------------------------------------------------------
/app/common/components/Button/index.story.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { storiesOf } from '@storybook/react';
3 |
4 | import Button from './index';
5 |
6 | storiesOf('components/Button', module)
7 | .add('General', () => (
8 | Default
9 | ))
10 | .add('Block', () => (
11 | Block
12 | ));
13 |
--------------------------------------------------------------------------------
/app/common/components/Button/styles.scss:
--------------------------------------------------------------------------------
1 | @import "variables.scss";
2 |
3 | .root {
4 | padding: 8px 12px;
5 | border-radius: 6px;
6 | background-color: $blue;
7 | color: white;
8 | min-width: 120px;
9 | cursor: pointer;
10 | display: inline-block;
11 | text-decoration: none;
12 | }
13 |
14 | .isBlock {
15 | width: 100%;
16 | display: block;
17 | }
18 |
--------------------------------------------------------------------------------
/app/common/components/ErrorMessages/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { compose } from 'recompose';
3 | import { translate } from 'react-i18next';
4 | import { ErrorMessages, ErrorMessage } from 'react-nebo15-validate';
5 |
6 | const ErrorMessagesTranslated = ({ children, t, ...rest }) => (
7 |
8 | {children}
9 | {t('Required field')}
10 | {t('Min length is <%= params %>')}
11 |
12 | );
13 |
14 | export default compose(
15 | translate()
16 | )(ErrorMessagesTranslated);
17 |
--------------------------------------------------------------------------------
/app/common/components/Form/FormRow/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import withStyles from 'withStyles';
4 | import { compose } from 'recompose';
5 | import styles from './styles.scss';
6 |
7 | const FormRow = ({ label, children, ...rest }) => (
8 |
9 | { label &&
{ label }
}
10 |
11 | { children }
12 |
13 |
14 | );
15 |
16 | FormRow.propTypes = {
17 | children: PropTypes.node,
18 | label: PropTypes.node,
19 | };
20 |
21 | export default compose(
22 | withStyles(styles)
23 | )(FormRow);
24 |
--------------------------------------------------------------------------------
/app/common/components/Form/FormRow/styles.scss:
--------------------------------------------------------------------------------
1 | .root {
2 | margin-top: 20px;
3 |
4 | &:first-child {
5 | margin-top: 0;
6 | }
7 | }
8 |
9 | .label {
10 | margin-bottom: 10px;
11 | font-size: 14px;
12 | font-weight: 400;
13 | }
14 |
--------------------------------------------------------------------------------
/app/common/components/Form/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import withStyles from 'withStyles';
3 | import { compose } from 'recompose';
4 | import styles from './styles.scss';
5 |
6 | const Form = ({ ...rest }) => (
7 |
8 | );
9 |
10 | export FormRow from './FormRow';
11 |
12 | export default compose(
13 | withStyles(styles)
14 | )(Form);
15 |
--------------------------------------------------------------------------------
/app/common/components/Form/index.story.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { storiesOf } from '@storybook/react';
3 |
4 | import Form, { FormRow } from './index';
5 |
6 | storiesOf('components/Form', module)
7 | .add('General', () => (
8 |
12 | ));
13 |
--------------------------------------------------------------------------------
/app/common/components/Form/styles.scss:
--------------------------------------------------------------------------------
1 | .root {
2 | display: block;
3 | width: 100%;
4 | }
5 |
--------------------------------------------------------------------------------
/app/common/components/FormField/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import withStyles from 'withStyles';
4 | import { compose, withProps, pure } from 'recompose';
5 | import ErrorMessages from '@/components/ErrorMessages';
6 | import styles from './styles.scss';
7 |
8 | const FormField = ({ input, meta, showError, inputComponent: InputComponent }) => (
9 |
10 |
11 |
12 |
13 | { showError && (
14 |
15 |
16 |
17 | )}
18 |
19 | );
20 |
21 | FormField.propTypes = {
22 | input: PropTypes.object.isRequired,
23 | meta: PropTypes.object.isRequired,
24 | inputComponent: PropTypes.func.isRequired,
25 | showError: PropTypes.bool,
26 | };
27 |
28 | export default compose(
29 | withStyles(styles),
30 | withProps(({ meta }) => ({
31 | showError: !!((meta.submitFailed || (meta.touched && !meta.active)) && meta.error),
32 | })),
33 | pure
34 | )(FormField);
35 |
--------------------------------------------------------------------------------
/app/common/components/FormField/styles.scss:
--------------------------------------------------------------------------------
1 | @import "variables.scss";
2 |
3 | .error {
4 | margin-top: 8px;
5 | color: $red;
6 | font-size: 10px;
7 | }
8 |
--------------------------------------------------------------------------------
/app/common/components/Icon/icons.font.js:
--------------------------------------------------------------------------------
1 |
2 | module.exports = {
3 | files: [
4 | 'icons/*.svg', // glob style
5 | ],
6 | fontName: 'fontIcons',
7 | classPrefix: 'icon-',
8 | baseSelector: '.icon',
9 | fixedWidth: true,
10 | types: ['eot', 'woff', 'ttf', 'svg'], // this is the default
11 | cssTemplate: 'templates/css.hbs',
12 | };
13 |
--------------------------------------------------------------------------------
/app/common/components/Icon/icons/add.svg:
--------------------------------------------------------------------------------
1 | Прямоугольник 41 Created with Avocode.
--------------------------------------------------------------------------------
/app/common/components/Icon/icons/arrow-down.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/app/common/components/Icon/icons/arrow-left-large.svg:
--------------------------------------------------------------------------------
1 | Прямоугольник 45 Created with Avocode.
--------------------------------------------------------------------------------
/app/common/components/Icon/icons/arrow-left.svg:
--------------------------------------------------------------------------------
1 | arrow_left Created with Avocode.
--------------------------------------------------------------------------------
/app/common/components/Icon/icons/arrow-right.svg:
--------------------------------------------------------------------------------
1 | arrow_right Created with Avocode.
--------------------------------------------------------------------------------
/app/common/components/Icon/icons/arrows-expand.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
6 |
7 |
10 |
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 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
--------------------------------------------------------------------------------
/app/common/components/Icon/icons/arrows-reduce.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
6 |
7 |
10 |
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 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
--------------------------------------------------------------------------------
/app/common/components/Icon/icons/check-right.svg:
--------------------------------------------------------------------------------
1 | Прямоугольник 24 Created with Avocode.
--------------------------------------------------------------------------------
/app/common/components/Icon/icons/doc.svg:
--------------------------------------------------------------------------------
1 |
3 | Фигура 7 копия 2
4 | Created with Avocode.
5 |
6 |
7 |
9 |
11 |
13 |
15 |
16 |
17 |
20 |
23 |
26 |
29 |
--------------------------------------------------------------------------------
/app/common/components/Icon/icons/eye.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | eye
5 | Created with Sketch.
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/app/common/components/Icon/icons/trash.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/common/components/Icon/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import classnames from 'classnames';
4 | import withStyles from 'withStyles';
5 | import styles from './icons.font';
6 |
7 | export const icons = [
8 | 'arrow-left',
9 | 'arrow-left-large',
10 | 'arrow-right',
11 | 'arrow-down',
12 | 'check-right',
13 | 'add',
14 | 'doc',
15 | 'trash',
16 | 'eye',
17 | 'arrows-expand',
18 | 'arrows-reduce',
19 | ];
20 |
21 | const Icon = ({ name }) => React.createElement('i', {
22 | className: classnames(styles.icon, styles[`icon-${name}`]),
23 | });
24 |
25 | Icon.propTypes = {
26 | name: PropTypes.oneOf(icons).isRequired,
27 | };
28 |
29 | export default withStyles(styles)(Icon);
30 |
--------------------------------------------------------------------------------
/app/common/components/Icon/index.story.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { storiesOf } from '@storybook/react';
3 |
4 | import Icon, { icons } from './index';
5 |
6 | storiesOf('components/Icon', module)
7 | .add('General', () => (
8 |
9 | { icons.map(iconName => (
10 |
11 | ))}
12 |
13 | ))
14 | .add('Change color', () => (
15 |
16 | { icons.map(iconName => (
17 |
18 | ))}
19 |
20 | ))
21 | .add('Change size', () => (
22 |
23 | { icons.map(iconName => (
24 |
25 | ))}
26 |
27 | ));
28 |
--------------------------------------------------------------------------------
/app/common/components/Icon/templates/css.hbs:
--------------------------------------------------------------------------------
1 | @font-face {
2 | font-family: "{{fontName}}";
3 | src: {{{src}}};
4 | }
5 |
6 | {{baseSelector}} {
7 | line-height: 1;
8 | display: inline-block;
9 | }
10 |
11 | {{baseSelector}}:before {
12 | font-family: {{fontName}} !important;
13 | font-style: normal;
14 | font-weight: normal !important;
15 | vertical-align: top;
16 | text-rendering: auto;
17 | speak: none;
18 | line-height: 1;
19 | -webkit-font-smoothing: antialiased;
20 | -moz-osx-font-smoothing: grayscale;
21 | }
22 |
23 | {{#each codepoints}}
24 | .{{../classPrefix}}{{@key}}:before {
25 | content: "\\{{this}}";
26 | }
27 | {{/each}}
28 |
--------------------------------------------------------------------------------
/app/common/components/Poster/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import withStyles from 'withStyles';
4 | import { compose } from 'recompose';
5 | import styles from './styles.scss';
6 |
7 | const Poster = ({ src, title }) => (
8 |
9 | );
10 |
11 | Poster.propTypes = {
12 | children: PropTypes.node,
13 | };
14 |
15 | export default compose(
16 | withStyles(styles)
17 | )(Poster);
18 |
--------------------------------------------------------------------------------
/app/common/components/Poster/index.story.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { storiesOf } from '@storybook/react';
3 |
4 | import Poster from './index';
5 |
6 | const posterUrl = 'https://ia.media-imdb.com/images/M/MV5BM2MyNjYxNmUtYTAwNi00MTYxLWJmNWYtYzZlODY3ZTk3OTFlXkEyXkFqcGdeQXVyNzkwMjQ5NzM@._V1_SY1000_CR0,0,704,1000_AL_.jpg';
7 |
8 | storiesOf('components/Poster', module)
9 | .add('General', () => (
10 |
11 | ));
12 |
--------------------------------------------------------------------------------
/app/common/components/Poster/styles.scss:
--------------------------------------------------------------------------------
1 | .root {
2 | background-size: cover;
3 | background-position: top center;
4 | width: 300px;
5 | height: 400px;
6 | }
7 |
--------------------------------------------------------------------------------
/app/common/components/TextInput/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import withStyles from 'withStyles';
4 | import classnames from 'classnames';
5 | import { compose } from 'recompose';
6 |
7 | import styles from './styles.scss';
8 |
9 | const TextInput = ({ error, ...rest }) => (
10 |
11 | );
12 |
13 | TextInput.propTypes = {
14 | error: PropTypes.bool,
15 | };
16 |
17 | export default compose(
18 | withStyles(styles)
19 | )(TextInput);
20 |
--------------------------------------------------------------------------------
/app/common/components/TextInput/index.story.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { storiesOf } from '@storybook/react';
3 | import { action } from '@storybook/addon-actions';
4 |
5 | import TextInput from './index';
6 |
7 | storiesOf('components/TextInput', module)
8 | .add('General', () => (
9 |
10 | ));
11 |
--------------------------------------------------------------------------------
/app/common/components/TextInput/styles.scss:
--------------------------------------------------------------------------------
1 | @import "variables.scss";
2 |
3 | .input {
4 | padding: 8px 12px;
5 | border: 1px solid $blue-grey;
6 | border-radius: 3px;
7 | width: 100%;
8 | display: block;
9 | }
10 |
11 | .isError {
12 | border-color: $red;
13 | }
14 |
--------------------------------------------------------------------------------
/app/common/components/TextareaInput/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import withStyles from 'withStyles';
4 | import classnames from 'classnames';
5 | import { compose } from 'recompose';
6 | import styles from './styles.scss';
7 |
8 | const TextareaInput = ({ error, ...rest }) => (
9 |
10 | );
11 |
12 | TextareaInput.propTypes = {
13 | error: PropTypes.bool,
14 | };
15 |
16 | export default compose(
17 | withStyles(styles)
18 | )(TextareaInput);
19 |
--------------------------------------------------------------------------------
/app/common/components/TextareaInput/index.story.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { storiesOf } from '@storybook/react';
3 | import { action } from '@storybook/addon-actions';
4 |
5 | import TextareaInput from './index';
6 |
7 | storiesOf('components/TextareaInput', module)
8 | .add('General', () => (
9 |
10 | ));
11 |
--------------------------------------------------------------------------------
/app/common/components/TextareaInput/styles.scss:
--------------------------------------------------------------------------------
1 | @import "variables.scss";
2 |
3 | .root {
4 | padding: 8px 12px;
5 | border: 1px solid $blue-grey;
6 | border-radius: 3px;
7 | width: 100%;
8 | display: block;
9 | }
10 |
11 | .isError {
12 | border-color: $red;
13 | }
14 |
--------------------------------------------------------------------------------
/app/common/components/_Component_/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import withStyles from 'withStyles';
4 | import { compose } from 'recompose';
5 | import styles from './styles.scss';
6 |
7 | const Component = ({ children }) => (
8 |
9 | { children }
10 |
11 | );
12 |
13 | Component.propTypes = {
14 | children: PropTypes.node,
15 | };
16 |
17 | export default compose(
18 | withStyles(styles)
19 | )(Component);
20 |
--------------------------------------------------------------------------------
/app/common/components/_Component_/index.story.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { storiesOf } from '@storybook/react';
3 |
4 | import Component from './index';
5 |
6 | storiesOf('components/_Component_', module)
7 | .add('General', () => (
8 | Sample component
9 | ));
10 |
--------------------------------------------------------------------------------
/app/common/components/_Component_/styles.scss:
--------------------------------------------------------------------------------
1 | .root {
2 | padding: 20px;
3 | box-shadow: 0 2px 4px 1px rgba(0, 0, 0, 0.3);
4 | }
5 |
6 | .bold {
7 | font-weight: 600;
8 | }
9 |
--------------------------------------------------------------------------------
/app/common/config.js:
--------------------------------------------------------------------------------
1 |
2 | let config = {};
3 |
4 | if (__CLIENT__ && window && window.__CONFIG__) {
5 | try {
6 | config = JSON.parse(unescape(window.__CONFIG__));
7 | } catch (e) {} // eslint-disable-line
8 | }
9 |
10 | export const PORT = config.PORT || process.env.PORT || 8080;
11 | export const HOSTNAME = typeof window !== 'undefined' ? window.location.origin : (config.HOSTNAME || `http://localhost:${PORT}`);
12 | export const API_ENDPOINT = config.API_ENDPOINT || process.env.API_ENDPOINT || `http://localhost:${PORT}/api`;
13 |
14 | export const SITEMAP_HOSTNAME = config.SITEMAP_HOSTNAME || process.env.SITEMAP_HOSTNAME || `http://localhost:${PORT}`; // used in sitemap
15 | export const LANG_COOKIE_NAME = config.LANG_COOKIE_NAME || process.env.LANG_COOKIE_NAME || 'lang';
16 |
17 | export const API_PROXY_PATH = '/api';
18 |
19 | // for internal app usage. for example for XHR requests or server side rendering
20 | export const API_URL = typeof window !== 'undefined' ? API_PROXY_PATH : API_ENDPOINT;
21 |
--------------------------------------------------------------------------------
/app/common/containers/blocks/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FrontBand/react-boilerplate/f76ddc9b93d804180e908bb0ff47825108f05da5/app/common/containers/blocks/.gitkeep
--------------------------------------------------------------------------------
/app/common/containers/blocks/MovieCard/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import withStyles from 'withStyles';
4 | import { compose } from 'recompose';
5 | import Poster from '@/components/Poster';
6 | import styles from './styles.scss';
7 |
8 | const MovieCard = ({ movie, ...rest }) => (
9 |
10 |
13 |
14 |
15 | { movie.title }
16 |
17 |
18 |
19 | );
20 |
21 | MovieCard.propTypes = {
22 | children: PropTypes.node,
23 | };
24 |
25 | export default compose(
26 | withStyles(styles)
27 | )(MovieCard);
28 |
--------------------------------------------------------------------------------
/app/common/containers/blocks/MovieCard/index.story.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { storiesOf } from '@storybook/react';
3 |
4 | import MovieCard from './index';
5 |
6 | storiesOf('blocks/MovieCard', module)
7 | .add('General', () => (
8 |
18 | ));
19 |
--------------------------------------------------------------------------------
/app/common/containers/blocks/MovieCard/styles.scss:
--------------------------------------------------------------------------------
1 |
2 | .root {
3 | position: relative;
4 | box-shadow: 0 2px 10px 0 rgba(0, 0, 0, 0.1);
5 | border-radius: 8px;
6 | cursor: pointer;
7 | overflow: hidden;
8 | width: 300px;
9 | height: 400px;
10 |
11 | &:hover {
12 | box-shadow: 0 2px 10px 2px rgba(0, 0, 0, 0.1);
13 | }
14 | }
15 |
16 | .poster {
17 | position: relative;
18 |
19 | &::after {
20 | content: '';
21 | display: block;
22 | position: absolute;
23 | top: 0;
24 | left: 0;
25 | right: 0;
26 | bottom: 0;
27 | background-image: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.3) 70%, rgba(0, 0, 0, 1));
28 | transition: opacity 0.2s linear;
29 | }
30 |
31 | &:hover::after {
32 | opacity: 0.8;
33 | }
34 | }
35 |
36 | .content {
37 | position: absolute;
38 | padding: 20px;
39 | bottom: 0;
40 | left: 0;
41 | right: 0;
42 | max-height: 100%;
43 | color: white;
44 | }
45 |
46 | .title {
47 | font-size: 14px;
48 | font-weight: 600;
49 | }
50 |
--------------------------------------------------------------------------------
/app/common/containers/forms/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FrontBand/react-boilerplate/f76ddc9b93d804180e908bb0ff47825108f05da5/app/common/containers/forms/.gitkeep
--------------------------------------------------------------------------------
/app/common/containers/forms/MovieForm/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { compose } from 'recompose';
3 | import { reduxForm, Field } from 'redux-form';
4 | import { translate } from 'react-i18next';
5 |
6 | import Form, { FormRow } from '@/components/Form';
7 | import FormField from '@/components/FormField';
8 | import TextInput from '@/components/TextInput';
9 | import TextareaInput from '@/components/TextareaInput';
10 | import Button from '@/components/Button';
11 |
12 | import { reduxFormValidate } from 'react-nebo15-validate';
13 |
14 | const MovieForm = ({ handleSubmit, t }) => (
15 |
35 | );
36 |
37 | export default compose(
38 | translate(),
39 | reduxForm({
40 | form: 'movie-form',
41 | initialValues: {},
42 | validate: reduxFormValidate({
43 | title: {
44 | required: true,
45 | },
46 | poster: {
47 | required: true,
48 | },
49 | description: {
50 | required: true,
51 | },
52 | year: {
53 | required: true,
54 | },
55 | director: {
56 | required: true,
57 | },
58 | }),
59 | })
60 | )(MovieForm);
61 |
--------------------------------------------------------------------------------
/app/common/containers/layouts/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FrontBand/react-boilerplate/f76ddc9b93d804180e908bb0ff47825108f05da5/app/common/containers/layouts/.gitkeep
--------------------------------------------------------------------------------
/app/common/containers/layouts/App/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import Helmet from 'react-helmet';
3 | import { compose } from 'recompose';
4 | import withStyles from 'withStyles';
5 |
6 | import styles from './styles.scss';
7 |
8 | const App = ({ children }) => (
9 |
37 | { children }
38 | );
39 |
40 | export default compose(
41 | withStyles(styles)
42 | )(App);
43 |
--------------------------------------------------------------------------------
/app/common/containers/layouts/App/styles.scss:
--------------------------------------------------------------------------------
1 | /* stylelint-disable */
2 | html, body, div, span, applet, object, iframe,h1, h2, h3, h4, h5,
3 | h6, p, blockquote, pre,a, abbr, acronym, address, big,
4 | cite, code,del, dfn, em, img, ins, kbd, q, s, samp,small,
5 | strike, strong, sub, sup, tt, var,b, u, i, center,dl, dt,
6 | dd, ol, ul, li,fieldset, form, label, legend,table, caption,
7 | tbody, tfoot, thead, tr, th, td,article, aside, canvas, details,
8 | embed, figure, figcaption, footer, header, hgroup, menu, nav,
9 | output, ruby, section, summary, time, mark, audio, video {
10 | margin: 0;
11 | padding: 0;
12 | border: 0;
13 | vertical-align: baseline
14 | }
15 |
16 | textarea, input, body {
17 | text-rendering: optimizeLegibility;
18 | -webkit-font-smoothing: antialiased;
19 | -moz-osx-font-smoothing: grayscale;
20 | }
21 |
22 | img,fieldset, a img {
23 | border: none;
24 | }
25 |
26 | input,
27 | textarea {
28 | -webkit-appearance: none;
29 | border-radius: 0;
30 | }
31 |
32 | textarea {
33 | overflow: auto;
34 | }
35 |
36 | input,
37 | button {
38 | margin: 0;
39 | padding: 0;
40 | border: 0;
41 | }
42 | input[type="submit"],
43 | button {
44 | cursor: pointer
45 | }
46 |
47 | button {
48 | -webkit-appearance: none;
49 | background: none;
50 | display: inline-block;
51 | color: inherit;
52 | font-size: inherit;
53 | }
54 |
55 | div, input, textarea, select,button,
56 | h1,h2,h3,h4,h5,h6,a,span,a:focus {
57 | outline: none;
58 | }
59 | ul,ol {
60 | list-style-type: none;
61 | }
62 |
63 | table {
64 | border-spacing: 0;
65 | border-collapse: collapse;
66 | width: 100%;
67 | }
68 |
69 | html {
70 | box-sizing: border-box;
71 | }
72 |
73 | *, *:before, *:after {
74 | box-sizing: border-box;
75 | }
76 | /* stylelint-enable */
77 |
78 | body {
79 | background: #f9fafa;
80 | }
81 |
82 | body,
83 | input,
84 | textarea,
85 | button {
86 | font: 400 14px/120% -apple-system, system-ui, "Segoe UI", Helvetica, Arial, sans-serif;
87 | color: #333;
88 | }
89 |
--------------------------------------------------------------------------------
/app/common/containers/layouts/Main/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import withStyles from 'withStyles';
3 |
4 | import styles from './styles.scss';
5 |
6 | const Main = ({ children }) => (
7 |
8 | { children }
9 |
10 | );
11 | export default withStyles(styles)(Main);
12 |
--------------------------------------------------------------------------------
/app/common/containers/layouts/Main/styles.scss:
--------------------------------------------------------------------------------
1 |
2 | .main {
3 | padding: 40px;
4 | }
5 |
--------------------------------------------------------------------------------
/app/common/containers/pages/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FrontBand/react-boilerplate/f76ddc9b93d804180e908bb0ff47825108f05da5/app/common/containers/pages/.gitkeep
--------------------------------------------------------------------------------
/app/common/containers/pages/MoviesCreatePage/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { compose, withHandlers } from 'recompose';
3 | import { connect } from 'react-redux';
4 | import { withRouter, Link } from 'react-router';
5 | import { translate } from 'react-i18next';
6 | import { createMovie } from '@/redux/data/movies';
7 |
8 | import MovieForm from '@/containers/forms/MovieForm';
9 |
10 | import withStyles from 'withStyles';
11 | import styles from './styles.scss';
12 |
13 | const MoviesCreatePage = ({ onSubmit, t }) => (
14 |
15 |
16 | {t('Create new movie')}
17 |
18 |
19 | {t('Back to the list of movies')}
20 |
21 |
22 |
23 |
24 |
25 | );
26 |
27 | export default compose(
28 | withStyles(styles),
29 | translate(),
30 | withRouter,
31 | connect(null, {
32 | createMovie,
33 | }),
34 | withHandlers({
35 | onSubmit: ({ createMovie, router }) => async (formValues) => {
36 | const response = await createMovie(formValues);
37 | router.push(`/movies/${response.payload.result}`);
38 | },
39 | })
40 | )(MoviesCreatePage);
41 |
--------------------------------------------------------------------------------
/app/common/containers/pages/MoviesCreatePage/styles.scss:
--------------------------------------------------------------------------------
1 | .root {
2 | max-width: 400px;
3 | }
4 |
5 | .title {
6 | font-size: 25px;
7 | font-weight: bold;
8 | }
9 |
10 | .form {
11 | margin-top: 40px;
12 | }
13 |
14 | .back {
15 | margin-top: 10px;
16 | }
17 |
--------------------------------------------------------------------------------
/app/common/containers/pages/MoviesDetailsPage/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { compose } from 'recompose';
3 | import { connect } from 'react-redux';
4 | import { withRouter, Link } from 'react-router';
5 | import { provideHooks } from 'redial';
6 | import { translate } from 'react-i18next';
7 | import { fetchMovie } from '@/redux/data/movies';
8 | import { getMovie } from '@/redux';
9 |
10 | import Poster from '@/components/Poster';
11 |
12 | import withStyles from 'withStyles';
13 | import styles from './styles.scss';
14 |
15 | const MoviesDetailsPage = ({ movie = {}, t }) => (
16 |
17 |
20 |
21 |
22 | { movie.title }
23 |
24 |
25 |
{ movie.year }
26 |
{ movie.description }
27 |
{ movie.director }
28 |
29 | {t('Back to the list of movies')}
30 |
31 |
32 |
33 |
34 | );
35 |
36 | export default compose(
37 | withStyles(styles),
38 | translate(),
39 | withRouter,
40 | provideHooks({
41 | fetch: ({ dispatch, params, setProps }) => dispatch(fetchMovie(params.id)).then((response) => {
42 | setProps({
43 | movieId: response.payload.result,
44 | });
45 | }),
46 | }),
47 | connect((state, ownProps) => ({
48 | movie: getMovie(state, ownProps.movieId),
49 | })),
50 | )(MoviesDetailsPage);
51 |
--------------------------------------------------------------------------------
/app/common/containers/pages/MoviesDetailsPage/styles.scss:
--------------------------------------------------------------------------------
1 | .root {
2 | display: flex;
3 | flex-direction: row;
4 | flex-wrap: nowrap;
5 | }
6 |
7 | .poster {
8 | flex: 0 0 auto;
9 | }
10 |
11 | .content {
12 | flex: 1 1 auto;
13 | padding: 20px;
14 | max-width: 450px;
15 | }
16 |
17 | .poster + .content {
18 | margin-left: 20px;
19 | }
20 |
21 | .title {
22 | font-size: 25px;
23 | font-weight: bold;
24 | }
25 |
26 | .info {
27 | p,
28 | ul {
29 | margin-top: 10px;
30 | }
31 | }
32 |
33 | .title + .info {
34 | margin-top: 30px;
35 | }
36 |
--------------------------------------------------------------------------------
/app/common/containers/pages/MoviesListPage/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { compose, withHandlers } from 'recompose';
3 | import { connect } from 'react-redux';
4 | import { withRouter } from 'react-router';
5 | import { provideHooks } from 'redial';
6 | import { fetchMovies } from '@/redux/data/movies';
7 | import { getMovies } from '@/redux';
8 | import { translate } from 'react-i18next';
9 |
10 | import Button from '@/components/Button';
11 | import MovieCard from '@/containers/blocks/MovieCard';
12 | import withStyles from 'withStyles';
13 | import styles from './styles.scss';
14 |
15 | const MoviesListPage = ({ movies, onMovieCardClick, t }) => (
16 |
17 |
18 | { movies.map(movie => (
19 |
20 | onMovieCardClick(movie)} />
21 |
22 | ))}
23 |
24 |
25 | {t('Add new movie')}
26 |
27 |
28 | );
29 |
30 | export default compose(
31 | withStyles(styles),
32 | translate(),
33 | withRouter,
34 | provideHooks({
35 | fetch: ({ dispatch, setProps }) => dispatch(fetchMovies()).then((response) => {
36 | setProps({ moviesIds: response.payload.result });
37 | }),
38 | }),
39 | connect((state, ownProps) => ({
40 | movies: getMovies(state, ownProps.moviesIds || []),
41 | })),
42 | withHandlers({
43 | onMovieCardClick: ({ router }) => (movie) => {
44 | router.push(`/movies/${movie.id}`);
45 | },
46 | })
47 | )(MoviesListPage);
48 |
--------------------------------------------------------------------------------
/app/common/containers/pages/MoviesListPage/styles.scss:
--------------------------------------------------------------------------------
1 |
2 | .root {
3 | overflow: hidden;
4 | }
5 |
6 | .list {
7 | margin-top: -16px;
8 | margin-left: -16px;
9 | }
10 |
11 | .item {
12 | margin-left: 16px;
13 | margin-top: 16px;
14 | display: inline-block;
15 | vertical-align: top;
16 | width: 300px;
17 | }
18 |
19 | .action {
20 | margin-top: 20px;
21 | }
22 |
--------------------------------------------------------------------------------
/app/common/containers/pages/NotFoundPage/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { compose } from 'recompose';
3 | import { translate } from 'react-i18next';
4 |
5 | const NotFoundPage = ({ t }) => (
6 |
7 |
{t('Page Not Found')}
8 |
9 | );
10 |
11 | export default compose(
12 | translate()
13 | )(NotFoundPage);
14 |
--------------------------------------------------------------------------------
/app/common/helpers/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FrontBand/react-boilerplate/f76ddc9b93d804180e908bb0ff47825108f05da5/app/common/helpers/.gitkeep
--------------------------------------------------------------------------------
/app/common/helpers/tests/mount.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { configureStore } from '@/store';
4 | import { Provider } from 'react-redux';
5 | import createMemoryHistory from 'history/lib/createMemoryHistory';
6 | import { mount } from 'enzyme';
7 | import WithStylesContext from '@/WithStylesContext';
8 |
9 | export default (component, ...args) => mount(
10 |
11 |
12 | {component}
13 |
14 | ,
15 | ...args
16 | );
17 |
--------------------------------------------------------------------------------
/app/common/helpers/url.js:
--------------------------------------------------------------------------------
1 | import Url from 'url';
2 | import qs from 'qs';
3 |
4 | export const createUrl = (endpoint, options) => {
5 | const url = Url.parse(endpoint, false);
6 |
7 | url.search = qs.stringify({
8 | ...qs.parse(url.search),
9 | ...options,
10 | });
11 | return Url.format(url);
12 | };
13 |
--------------------------------------------------------------------------------
/app/common/helpers/url.test.js:
--------------------------------------------------------------------------------
1 | import { createUrl } from './url';
2 |
3 | describe('createUrl', () => {
4 | test('add query params', () => {
5 | const url = createUrl('http://localhost', { test: 1 });
6 | expect(url).toBe('http://localhost/?test=1');
7 | });
8 | });
9 |
--------------------------------------------------------------------------------
/app/common/helpers/validate.js:
--------------------------------------------------------------------------------
1 | import setFn from 'lodash/set';
2 |
3 | export const mapServerErrorsToClient = (
4 | error, mapServerToClient = {}
5 | ) => error.invalid.reduce((prev, cur) => {
6 | const serverPath = cur.entry.slice(2);
7 | const clientPath = mapServerToClient[serverPath] || serverPath;
8 | setFn(prev, clientPath, cur.rules.reduce((prevErr, i) => {
9 | switch (i.rule) {
10 | case 'number':
11 | return setFn(prevErr, i.rule, {
12 | min: i.params.greater_than_or_equal_to,
13 | max: i.params.less_than_or_equal_to,
14 | });
15 | default:
16 | break;
17 | }
18 | return setFn(prevErr, i.rule, i.params);
19 | }, {}));
20 | return prev;
21 | }, {});
22 |
--------------------------------------------------------------------------------
/app/common/locales/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FrontBand/react-boilerplate/f76ddc9b93d804180e908bb0ff47825108f05da5/app/common/locales/.gitkeep
--------------------------------------------------------------------------------
/app/common/locales/en.po:
--------------------------------------------------------------------------------
1 | msgid ""
2 | msgstr ""
3 | "Project-Id-Version: \n"
4 | "POT-Creation-Date: 2017-04-11 23:26+0200\n"
5 | "PO-Revision-Date: 2017-04-11 23:29+0200\n"
6 | "Last-Translator: \n"
7 | "Language-Team: \n"
8 | "Language: en\n"
9 | "MIME-Version: 1.0\n"
10 | "Content-Type: text/plain; charset=UTF-8\n"
11 | "Content-Transfer-Encoding: 8bit\n"
12 | "X-Generator: Poedit 1.8.1\n"
13 | "X-Poedit-Basepath: .\n"
14 | "Plural-Forms: nplurals=2; plural=(n != 1);\n"
15 |
--------------------------------------------------------------------------------
/app/common/locales/ru.po:
--------------------------------------------------------------------------------
1 | msgid ""
2 | msgstr ""
3 | "Project-Id-Version: i18next-conv\n"
4 | "POT-Creation-Date: 2018-04-23 01:52+0300\n"
5 | "PO-Revision-Date: 2018-04-23 01:53+0300\n"
6 | "Last-Translator: \n"
7 | "Language-Team: \n"
8 | "Language: ru\n"
9 | "MIME-Version: 1.0\n"
10 | "Content-Type: text/plain; charset=UTF-8\n"
11 | "Content-Transfer-Encoding: 8bit\n"
12 | "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n"
13 | "%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
14 | "X-Generator: Poedit 1.8.11\n"
15 |
16 | msgid "Required field"
17 | msgstr "Обязательное поле"
18 |
19 | msgid "Title"
20 | msgstr "Название"
21 |
22 | msgid "Poster url"
23 | msgstr "URL постера"
24 |
25 | msgid "Description"
26 | msgstr "Описание"
27 |
28 | msgid "Year"
29 | msgstr "Год"
30 |
31 | msgid "Director"
32 | msgstr "Режиссер"
33 |
34 | msgid "Create"
35 | msgstr "Создать"
36 |
37 | msgid "Create new movie"
38 | msgstr "Добавить новый фильм"
39 |
40 | msgid "Back to the list of movies"
41 | msgstr "Вернуться к списку фильмов"
42 |
43 | msgid "Add new movie"
44 | msgstr "Добавить новый фильм"
45 |
46 | msgid "Page Not Found"
47 | msgstr "Страница не найдена"
48 |
--------------------------------------------------------------------------------
/app/common/locales/source.pot:
--------------------------------------------------------------------------------
1 | msgid ""
2 | msgstr ""
3 | "Project-Id-Version: i18next-conv\n"
4 | "MIME-Version: 1.0\n"
5 | "Content-Type: text/plain; charset=utf-8\n"
6 | "Content-Transfer-Encoding: 8bit\n"
7 | "Plural-Forms: nplurals=2; plural=(n != 1)\n"
8 | "POT-Creation-Date: 2018-04-26T12:08:55.361Z\n"
9 | "PO-Revision-Date: 2018-04-26T12:08:55.361Z\n"
10 |
11 | msgid "Required field"
12 | msgstr ""
13 |
14 | msgid "Title"
15 | msgstr ""
16 |
17 | msgid "Poster url"
18 | msgstr ""
19 |
20 | msgid "Description"
21 | msgstr ""
22 |
23 | msgid "Year"
24 | msgstr ""
25 |
26 | msgid "Director"
27 | msgstr ""
28 |
29 | msgid "Create"
30 | msgstr ""
31 |
32 | msgid "Create new movie"
33 | msgstr ""
34 |
35 | msgid "Back to the list of movies"
36 | msgstr ""
37 |
38 | msgid "Add new movie"
39 | msgstr ""
40 |
41 | msgid "Page Not Found"
42 | msgstr ""
--------------------------------------------------------------------------------
/app/common/locales/uk.po:
--------------------------------------------------------------------------------
1 | msgid ""
2 | msgstr ""
3 | "Project-Id-Version: i18next-conv\n"
4 | "POT-Creation-Date: 2017-04-11 23:28+0200\n"
5 | "PO-Revision-Date: 2017-04-11 23:28+0200\n"
6 | "Last-Translator: \n"
7 | "Language-Team: \n"
8 | "Language: uk\n"
9 | "MIME-Version: 1.0\n"
10 | "Content-Type: text/plain; charset=UTF-8\n"
11 | "Content-Transfer-Encoding: 8bit\n"
12 | "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n"
13 | "%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
14 | "X-Generator: Poedit 1.8.1\n"
15 |
--------------------------------------------------------------------------------
/app/common/redux/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FrontBand/react-boilerplate/f76ddc9b93d804180e908bb0ff47825108f05da5/app/common/redux/.gitkeep
--------------------------------------------------------------------------------
/app/common/redux/api.js:
--------------------------------------------------------------------------------
1 | import { CALL_API } from 'redux-api-middleware';
2 |
3 | export const invoke = config => (dispatch) => {
4 | const result = {
5 | ...config,
6 | };
7 |
8 | result.headers = {
9 | 'content-type': 'application/json',
10 | pragma: 'no-cache',
11 | 'cache-control': 'no-cache',
12 | ...result.headers,
13 | };
14 |
15 | if (typeof result.body !== 'string') {
16 | result.body = JSON.stringify(result.body);
17 | }
18 |
19 | return dispatch({
20 | [CALL_API]: result,
21 | }).then((action) => {
22 | if (action.error) throw action;
23 | return action;
24 | });
25 | };
26 |
--------------------------------------------------------------------------------
/app/common/redux/data/index.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from 'redux';
2 |
3 | import movies from './movies';
4 |
5 | export default combineReducers({
6 | movies,
7 | });
8 |
--------------------------------------------------------------------------------
/app/common/redux/data/movies.js:
--------------------------------------------------------------------------------
1 | import { handleAction, combineActions } from 'redux-actions';
2 | import { API_URL } from '@/config';
3 | import { normalize } from 'normalizr';
4 | import { createUrl } from '@/helpers/url';
5 | import { movie } from '@/schemas';
6 | import { invoke } from '@/redux/api';
7 |
8 | export const fetchMovies = options => invoke({
9 | endpoint: createUrl(`${API_URL}/movies`, options),
10 | method: 'GET',
11 | headers: {
12 | 'content-type': 'application/json',
13 | },
14 | types: ['movies/FETCH_LIST_REQUEST', {
15 | type: 'movies/FETCH_LIST_SUCCESS',
16 | payload: (action, state, res) => res.json().then(
17 | json => normalize(json.data, [movie])
18 | ),
19 | }, 'movies/FETCH_LIST_FAILURE'],
20 | });
21 |
22 | export const fetchMovie = (movieId, options) => invoke({
23 | endpoint: createUrl(`${API_URL}/movies/${movieId}`, options),
24 | method: 'GET',
25 | headers: {
26 | 'content-type': 'application/json',
27 | },
28 | types: ['movies/FETCH_DETAILS_REQUEST', {
29 | type: 'movies/FETCH_DETAILS_SUCCESS',
30 | payload: (action, state, res) => res.json().then(
31 | json => normalize(json.data, movie)
32 | ),
33 | }, 'movies/FETCH_DETAILS_FAILURE'],
34 | });
35 |
36 | export const createMovie = (body, options) => invoke({
37 | endpoint: createUrl(`${API_URL}/movies`, options),
38 | method: 'POST',
39 | headers: {
40 | 'content-type': 'application/json',
41 | },
42 | body: {
43 | movie: body,
44 | },
45 | types: ['movies/CREATE_REQUEST', {
46 | type: 'movies/CREATE_SUCCESS',
47 | payload: (action, state, res) => res.json().then(
48 | json => normalize(json.data, movie)
49 | ),
50 | }, 'movies/CREATE_FAILURE'],
51 | });
52 |
53 | export const updateMovie = (movieId, body, options) => invoke({
54 | endpoint: createUrl(`${API_URL}/movies/${movieId}`, options),
55 | method: 'PUT',
56 | headers: {
57 | 'content-type': 'application/json',
58 | },
59 | body: {
60 | movie: body,
61 | },
62 | types: ['movies/UPDATE_REQUEST', {
63 | type: 'movies/UPDATE_SUCCESS',
64 | payload: (action, state, res) => res.json().then(
65 | json => normalize(json.data, movie)
66 | ),
67 | }, 'movies/UPDATE_FAILURE'],
68 | });
69 |
70 | export const deleteMovie = (movieId, options) => invoke({
71 | endpoint: createUrl(`${API_URL}/movies/${movieId}`, options),
72 | method: 'DELETE',
73 | headers: {
74 | 'content-type': 'application/json',
75 | },
76 | types: ['movies/DELETE_REQUEST', 'movies/DELETE_SUCCESS', 'movies/DELETE_FAILURE'],
77 | });
78 |
79 | export default handleAction(
80 | combineActions(
81 | 'movies/FETCH_LIST_SUCCESS',
82 | 'movies/FETCH_DETAILS_SUCCESS',
83 | 'movies/CREATE_SUCCESS',
84 | 'movies/UPDATE_SUCCESS'
85 | ),
86 | (state, action) => ({
87 | ...state,
88 | ...action.payload.entities.movies,
89 | }),
90 | {}
91 | );
92 |
--------------------------------------------------------------------------------
/app/common/redux/index.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from 'redux';
2 | import { reducer as form } from 'redux-form';
3 | import { routerReducer as routing } from 'react-router-redux';
4 | import { denormalize } from 'normalizr';
5 | import * as schemas from '@/schemas';
6 |
7 | import loading from './ui/loading';
8 | import data from './data';
9 |
10 | const ui = combineReducers({
11 | loading,
12 | });
13 |
14 | export default combineReducers({
15 | data,
16 | // external libraries
17 | form,
18 | routing,
19 | ui,
20 | });
21 |
22 | export const getLocation = state => state.routing.locationBeforeTransitions;
23 | export const getForm = (state, formName) => state.form[formName];
24 |
25 | // basic set of reducers for data entities
26 | export const getMovie = (state, id) => denormalize(id, schemas.movie, state.data);
27 | export const getMovies = (state, ids) => ids.map(id => getMovie(state, id));
28 | export const getAllMovies = state => getMovies(state, Object.keys(state.data.movies));
29 |
--------------------------------------------------------------------------------
/app/common/redux/language.js:
--------------------------------------------------------------------------------
1 |
2 | export const changeLanguage = lang => (dispatch, getState, { i18n }) =>
3 | i18n.changeLanguage(lang);
4 |
--------------------------------------------------------------------------------
/app/common/redux/ui/loading.js:
--------------------------------------------------------------------------------
1 | import { createAction, handleActions } from 'redux-actions';
2 |
3 | export const showLoading = createAction('loading/SHOW');
4 | export const hideLoading = createAction('loading/HIDE');
5 |
6 | export default handleActions({
7 | [showLoading]: () => true,
8 | [hideLoading]: () => false,
9 | }, false);
10 |
--------------------------------------------------------------------------------
/app/common/routes/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FrontBand/react-boilerplate/f76ddc9b93d804180e908bb0ff47825108f05da5/app/common/routes/.gitkeep
--------------------------------------------------------------------------------
/app/common/routes/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Route, IndexRedirect } from 'react-router';
3 |
4 | import App from '@/containers/layouts/App';
5 | import Main from '@/containers/layouts/Main';
6 |
7 | import MoviesListPage from '@/containers/pages/MoviesListPage';
8 | import MoviesDetailsPage from '@/containers/pages/MoviesDetailsPage';
9 | import MoviesCreatePage from '@/containers/pages/MoviesCreatePage';
10 |
11 | import NotFoundPage from '@/containers/pages/NotFoundPage';
12 |
13 | export const configureRoutes = ({ store }) => { // eslint-disable-line
14 | // store can be used in future to check autentication of the user
15 | return (
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | );
28 | };
29 |
--------------------------------------------------------------------------------
/app/common/schemas/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FrontBand/react-boilerplate/f76ddc9b93d804180e908bb0ff47825108f05da5/app/common/schemas/.gitkeep
--------------------------------------------------------------------------------
/app/common/schemas/index.js:
--------------------------------------------------------------------------------
1 | import { schema } from 'normalizr';
2 |
3 | export const movie = new schema.Entity('movies');
4 |
--------------------------------------------------------------------------------
/app/common/services/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FrontBand/react-boilerplate/f76ddc9b93d804180e908bb0ff47825108f05da5/app/common/services/.gitkeep
--------------------------------------------------------------------------------
/app/common/services/i18next.js:
--------------------------------------------------------------------------------
1 | import i18n from 'i18next';
2 | import { LANG_COOKIE_NAME } from '@/config';
3 |
4 | import uk from '@/locales/uk.po';
5 | import ru from '@/locales/ru.po';
6 | import en from '@/locales/en.po';
7 |
8 | const LanguageDetector = __CLIENT__ ? require('i18next-browser-languagedetector') : require('i18next-express-middleware').LanguageDetector; // eslint-disable-line
9 |
10 | const service = i18n;
11 | service.use(new LanguageDetector(null, {
12 | order: ['querystring', 'cookie', 'navigator', 'htmlTag'],
13 | caches: ['cookie'],
14 | lookupCookie: LANG_COOKIE_NAME,
15 | lookupQuerystring: 'lang',
16 | }));
17 |
18 | service.init({
19 | nsSeparator: false,
20 | keySeparator: false,
21 | fallbackLng: 'uk',
22 | whitelist: ['uk', 'ru', 'en'],
23 | resources: {
24 | uk: {
25 | translation: uk,
26 | },
27 | ru: {
28 | translation: ru,
29 | },
30 | en: {
31 | translation: en,
32 | },
33 | },
34 | });
35 |
36 | export default service;
37 |
--------------------------------------------------------------------------------
/app/common/services/validations.js:
--------------------------------------------------------------------------------
1 | import { addValidation } from 'react-nebo15-validate';
2 |
3 | addValidation('custom', (value, params, allValues) => false); // eslint-disable-line
4 |
--------------------------------------------------------------------------------
/app/common/store/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FrontBand/react-boilerplate/f76ddc9b93d804180e908bb0ff47825108f05da5/app/common/store/.gitkeep
--------------------------------------------------------------------------------
/app/common/store/index.js:
--------------------------------------------------------------------------------
1 |
2 | import { createStore, applyMiddleware, compose } from 'redux';
3 | import { routerMiddleware } from 'react-router-redux';
4 | import promiseMiddleware from 'redux-promise';
5 | import thunkMiddleware from 'redux-thunk';
6 | import multiMiddleware from 'redux-multi';
7 | import { apiMiddleware } from 'redux-api-middleware';
8 |
9 | import rootReducer from '@/redux';
10 |
11 | const middlewares = [
12 | multiMiddleware, promiseMiddleware, apiMiddleware,
13 | ];
14 |
15 | if (process.NODE_ENV !== 'production') {
16 | middlewares.push(require('redux-freeze')); // eslint-disable-line global-require
17 | }
18 |
19 | export function configureStore ({ history, cookies, i18n }, initialState) { // eslint-disable-line
20 | const createStoreWithMiddleware = compose(
21 | applyMiddleware.apply(this, middlewares.concat([
22 | routerMiddleware(history),
23 | thunkMiddleware.withExtraArgument({ cookies, i18n }),
24 | ])),
25 | (process.NODE_ENV !== 'production') && global.window && window.devToolsExtension ? window.devToolsExtension() : f => f
26 | )(createStore);
27 |
28 | const store = createStoreWithMiddleware(rootReducer, initialState);
29 |
30 | return store;
31 | }
32 |
--------------------------------------------------------------------------------
/app/common/styles/variables.scss:
--------------------------------------------------------------------------------
1 | $breakpoint-desktop: 1024px;
2 | $breakpoint-tablet: 768px;
3 |
4 | $breakpoint-mobile-max: resolve($breakpoint-tablet - 1px);
5 |
6 | $blue-grey: #e4e8ec;
7 | $blue: #20adfc;
8 | $red: red;
9 |
--------------------------------------------------------------------------------
/app/server/__dev.js:
--------------------------------------------------------------------------------
1 | require('dotenv').config({ silent: true });
2 | require('babel-register')({
3 | presets: [
4 | 'es2015',
5 | 'react',
6 | 'stage-0',
7 | ],
8 | plugins: [
9 | 'transform-runtime',
10 | 'transform-decorators-legacy',
11 | [
12 | 'module-resolver',
13 | {
14 | root: ['../../app/common'],
15 | alias: {
16 | tests: '../../tests',
17 | withStyles: 'withStyles',
18 | public: '../../public',
19 | },
20 | },
21 | ],
22 | ],
23 | });
24 |
25 | require('ignore-styles').default(['.scss', '.css', '.po', '.png', '.jpg', '.svg']);
26 |
27 | global.__DEV__ = false; // eslint-disable-line
28 | global.__CLIENT__ = false; // eslint-disable-line
29 |
30 | require('./server');
31 |
--------------------------------------------------------------------------------
/app/server/api/index.js:
--------------------------------------------------------------------------------
1 | import Express from 'express';
2 | import uuid from 'uuid/v4';
3 | import { find, findIndex, omit } from 'lodash';
4 |
5 | const router = new Express.Router();
6 | router.use(Express.json());
7 |
8 | let movies = [
9 | {
10 | id: uuid(),
11 | title: 'The Shawshank Redemption',
12 | year: 1994,
13 | poster:
14 | 'https://ia.media-imdb.com/images/M/MV5BMDFkYTc0MGEtZmNhMC00ZDIzLWFmNTEtODM1ZmRlYWMwMWFmXkEyXkFqcGdeQXVyMTMxODk2OTU@._V1_.jpg',
15 | description:
16 | 'Two imprisoned men bond over a number of years, finding solace and eventual redemption through acts of common decency.',
17 | director: 'Frank Darabont',
18 | genres: ['Crime', 'Drama'],
19 | },
20 | {
21 | id: uuid(),
22 | title: 'The Godfather',
23 | year: 1972,
24 | poster:
25 | 'https://ia.media-imdb.com/images/M/MV5BM2MyNjYxNmUtYTAwNi00MTYxLWJmNWYtYzZlODY3ZTk3OTFlXkEyXkFqcGdeQXVyNzkwMjQ5NzM@._V1_SY1000_CR0,0,704,1000_AL_.jpg',
26 | description:
27 | 'The aging patriarch of an organized crime dynasty transfers control of his clandestine empire to his reluctant son.',
28 | director: 'Francis Ford Coppola',
29 | genres: ['Crime', 'Drama'],
30 | },
31 | {
32 | id: uuid(),
33 | title: 'The Dark Knight',
34 | year: 2008,
35 | poster:
36 | 'https://ia.media-imdb.com/images/M/MV5BMTMxNTMwODM0NF5BMl5BanBnXkFtZTcwODAyMTk2Mw@@._V1_SY1000_CR0,0,675,1000_AL_.jpg',
37 | description:
38 | 'When the menace known as the Joker emerges from his mysterious past, he wreaks havoc and chaos on the people of Gotham, the Dark Knight must accept one of the greatest psychological and physical tests of his ability to fight injustice.',
39 | director: 'Christopher Nolan',
40 | genres: ['Action', 'Crime', 'Drama'],
41 | },
42 | ];
43 |
44 | router.get('/movies', (req, res) => {
45 | res.json({
46 | data: movies,
47 | });
48 | });
49 |
50 | router.post('/movies', (req, res) => {
51 | const newMovie = {
52 | ...req.body.movie,
53 | id: uuid(),
54 | };
55 | movies.push(newMovie);
56 |
57 | res.json({
58 | data: newMovie,
59 | });
60 | });
61 |
62 | router.get('/movies/:id', (req, res) => {
63 | const movie = find(movies, { id: req.params.id });
64 | if (!movie) {
65 | return res.sendStatus(404);
66 | }
67 | return res.json({
68 | data: movie,
69 | });
70 | });
71 |
72 | router.put('/movies/:id', (req, res) => {
73 | const movieIndex = findIndex(movies, { id: req.params.id });
74 |
75 | if (movieIndex === -1) {
76 | return res.sendStatus(404);
77 | }
78 |
79 | const updatedMovie = omit(req.body.movie, ['id']);
80 | movies[movieIndex] = { ...movies[movieIndex], ...updatedMovie };
81 |
82 | return res.json({
83 | data: movies[movieIndex],
84 | });
85 | });
86 |
87 | router.delete('/movies/:id', (req, res) => {
88 | const movie = find(movies, { id: req.params.id });
89 | if (!movie) {
90 | return res.sendStatus(404);
91 | }
92 | movies = movies.filter(i => i.id !== req.params.id);
93 | return res.json({
94 | data: movies,
95 | });
96 | });
97 |
98 | export default router;
99 |
--------------------------------------------------------------------------------
/app/server/page.js:
--------------------------------------------------------------------------------
1 | import Set from 'core-js/library/fn/set';
2 | import arrayFrom from 'core-js/library/fn/array/from';
3 | import CookieDough from 'cookie-dough';
4 |
5 | import React from 'react';
6 | import ReactDOMServer from 'react-dom/server';
7 | import { useRouterHistory, match, Router, applyRouterMiddleware } from 'react-router';
8 |
9 | import Helmet from 'react-helmet';
10 |
11 | import { I18nextProvider } from 'react-i18next';
12 | import { triggerHooks, useRedial } from 'react-router-redial';
13 | import { syncHistoryWithStore } from 'react-router-redux';
14 |
15 | import { Provider } from 'react-redux';
16 |
17 | import createMemoryHistory from 'history/lib/createMemoryHistory';
18 | import useQueries from 'history/lib/useQueries';
19 |
20 | import { configureStore } from '@/store';
21 |
22 | import { configureRoutes } from '@/routes';
23 | import WithStylesContext from '@/WithStylesContext';
24 | import '@/services/validations';
25 |
26 | export default () => (req, res, next) => {
27 | if (__DEV__) {
28 | return res.render('index', {
29 | html: null,
30 | reduxState: null,
31 | inlineCss: null,
32 | redialProps: null,
33 | helmet: Helmet.rewind(),
34 | });
35 | }
36 |
37 | const memoryHistory = useRouterHistory(useQueries(createMemoryHistory))();
38 | const store = configureStore({
39 | history: memoryHistory,
40 | cookies: new CookieDough(req),
41 | i18n: req.i18n,
42 | });
43 | const history = syncHistoryWithStore(memoryHistory, store);
44 | const routes = configureRoutes({
45 | store,
46 | });
47 | const router = { routes } ;
48 | const historyLocation = history.createLocation(req.url);
49 |
50 | const { dispatch, getState } = store;
51 |
52 | return match({ routes: router, location: historyLocation }, (error, redirectLocation, renderProps) => { //eslint-disable-line
53 | if (redirectLocation) {
54 | return res.redirect(301, redirectLocation.pathname + redirectLocation.search);
55 | } else if (error) {
56 | return res.status(500).send(error.message);
57 | } else if (renderProps == null) {
58 | return res.status(404).send('Not found');
59 | }
60 |
61 | const route = renderProps.routes[renderProps.routes.length - 1];
62 | const locals = {
63 | // Allow lifecycle hooks to dispatch Redux actions:
64 | dispatch,
65 | getState,
66 | };
67 |
68 | // Wait for async data fetching to complete, then render:
69 | return triggerHooks({
70 | renderProps,
71 | locals,
72 | hooks: ['fetch', 'server', 'done'],
73 | }).then(({ redialMap, redialProps }) => {
74 | const reduxState = escape(JSON.stringify(getState()));
75 | const css = new Set();
76 | /* eslint-disable no-underscore-dangle */
77 | let html;
78 | const component = applyRouterMiddleware(useRedial({ redialMap }))(renderProps);
79 |
80 | try {
81 | html = ReactDOMServer.renderToString(
82 |
83 | styles._getCss && css.add(styles._getCss())}
85 | >
86 |
87 | { component }
88 |
89 |
90 |
91 | );
92 | } catch (e) {
93 | console.log('render error');
94 | console.error(e);
95 | html = null;
96 | }
97 |
98 | const helmet = Helmet.rewind();
99 |
100 | res.status(route.status || 200);
101 | res.render('index', {
102 | html,
103 | redialProps: escape(JSON.stringify(redialProps)),
104 | reduxState,
105 | helmet,
106 | inlineCss: arrayFrom(css).join(''),
107 | });
108 | })
109 | .catch(err => next(err));
110 | });
111 | };
112 |
--------------------------------------------------------------------------------
/app/server/server.js:
--------------------------------------------------------------------------------
1 |
2 | import Express from 'express';
3 | import path from 'path';
4 | import fs from 'fs';
5 | import cookieParser from 'cookie-parser';
6 | // import proxy from 'proxy-middleware';
7 | import i18nextMiddleware from 'i18next-express-middleware';
8 |
9 | import i18next from '@/services/i18next';
10 | import * as config from '@/config';
11 |
12 | import page from './page'; // eslint-disable-line import/no-unresolved
13 | import sitemap from './sitemap';
14 | import api from './api';
15 |
16 | const server = new Express();
17 |
18 | server.set('port', config.PORT);
19 |
20 | server.set('views', path.join(__dirname, 'views'));
21 | server.set('view engine', 'ejs');
22 |
23 | const resources = {
24 | js: [],
25 | css: [],
26 | };
27 |
28 | // NOTE: file is not exist while webpack compile. so we can't use require
29 | const assets = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../../static/webpack-assets.json'), 'utf8'));
30 |
31 | Object.keys(assets).forEach((key) => {
32 | if (assets[key].js) resources.js.push(assets[key].js);
33 | if (assets[key].css) resources.css.push(assets[key].css);
34 | });
35 |
36 | server.locals.resources = resources;
37 |
38 | server.locals.CONFIG = escape(JSON.stringify(config));
39 |
40 | server.use(cookieParser());
41 | server.use(i18nextMiddleware.handle(i18next));
42 |
43 | // example of proxy to avoid cors
44 | // server.use(config.API_PROXY_PATH, proxy(config.API_ENDPOINT));
45 | server.use('/api', api);
46 | server.use(Express.static(path.join(__dirname, '../../public')));
47 | server.use('/static', Express.static(path.join(__dirname, '../../static')));
48 | server.use(sitemap); // sitemap generation
49 | server.get('*', page()); // init SSR
50 |
51 | server.use((err, req, res) => {
52 | /* eslint-disable no-console */
53 | console.log(err.stack);
54 | // TODO report error here or do some further handlings
55 | res.status(500).send('something went wrong...');
56 | });
57 |
58 | server.listen(server.get('port'), (err) => {
59 | if (err) {
60 | /* eslint-disable no-console */
61 | console.log(err);
62 | return;
63 | }
64 |
65 | console.log(`Listening at http://localhost: ${server.get('port')}`);
66 | });
67 |
--------------------------------------------------------------------------------
/app/server/sitemap.js:
--------------------------------------------------------------------------------
1 | import Express from 'express';
2 |
3 | import path from 'path';
4 | import cron from 'node-cron';
5 | import SiteMap from 'react-router-sitemap';
6 |
7 | import { SITEMAP_HOSTNAME } from '@/config';
8 |
9 | import { configureRoutes } from '@/routes';
10 |
11 | let LAST_MODIFIED = null;
12 | /* eslint-disable no-unused-vars*/
13 | const task = cron.schedule('0 0 3 * * *', generateSitemap);
14 | const routes = configureRoutes({});
15 |
16 | generateSitemap();
17 |
18 | function writeSitemap() {
19 | const sitemap = new SiteMap(routes);
20 | LAST_MODIFIED = new Date();
21 |
22 | sitemap
23 | .build(SITEMAP_HOSTNAME)
24 | .save(path.resolve(__dirname, '../../public', 'sitemap_router.xml'));
25 | }
26 |
27 | function generateSitemap() {
28 | return writeSitemap();
29 | }
30 |
31 | const router = new Express.Router();
32 |
33 | router.get('/sitemap.xml', (req, res) => {
34 | res.header('Content-Type', 'text/xml');
35 | res.send(`
36 |
37 | ${SITEMAP_HOSTNAME}/sitemap_router.xml
38 | ${LAST_MODIFIED.toJSON()}
39 |
40 | `);
41 | });
42 | export default router;
43 |
--------------------------------------------------------------------------------
/app/server/views/index.ejs:
--------------------------------------------------------------------------------
1 |
2 | >
3 |
4 | <%- helmet.title.toString() %>
5 | <%- helmet.meta.toString() %>
6 | <%- helmet.link.toString() %>
7 | <% if (inlineCss) { %>
8 |
9 | <% } %>
10 |
11 |
12 | <%- html %>
13 |
14 | <% if (reduxState) { %>
15 |
18 | <% } %>
19 | <% if (CONFIG) { %>
20 |
23 | <% } %>
24 | <% if (redialProps) { %>
25 |
28 | <% } %>
29 |
30 | <% resources.js.forEach(function(src){ %>
31 |
32 | <% }) %>
33 | <% resources.css.forEach(function(src){ %>
34 |
35 | <% }) %>
36 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/bin/build.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # This script builds an image based on a Dockerfile and package.json that is located in root of git working tree.
4 |
5 | # Find package.json inside project tree.
6 | # This allows to call bash scripts within any folder inside project.
7 | PROJECT_DIR=$(git rev-parse --show-toplevel)
8 | if [ ! -f "${PROJECT_DIR}/package.json" ]; then
9 | echo "[E] Can't find '${PROJECT_DIR}/package.json'."
10 | echo " Check that you run this script inside git repo or init a new one in project root."
11 | fi
12 |
13 | # Extract project name and version from package.json
14 | PROJECT_NAME=$(cat "${PROJECT_DIR}/package.json" \
15 | | grep name \
16 | | head -1 \
17 | | awk -F: '{ print $2 }' \
18 | | sed 's/[",]//g' \
19 | | tr -d '[[:space:]]')
20 | PROJECT_VERSION=$(cat "${PROJECT_DIR}/package.json" \
21 | | grep version \
22 | | head -1 \
23 | | awk -F: '{ print $2 }' \
24 | | sed 's/[",]//g' \
25 | | tr -d '[[:space:]]')
26 |
27 | echo "[I] Building a Docker container '${PROJECT_NAME}' (version '${PROJECT_VERSION}') from path '${PROJECT_DIR}'.."
28 |
29 | docker build --tag "${PROJECT_NAME}:${PROJECT_VERSION}" \
30 | --file "${PROJECT_DIR}/Dockerfile" \
31 | $PROJECT_DIR
32 |
--------------------------------------------------------------------------------
/bin/ci/push.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | # This setup works with Tavis-CI.
3 | # You need to specify $DOCKER_HUB_ACCOUNT, $DOCKER_USERNAME and $DOCKER_PASSWORD before using this script.
4 |
5 | echo "Logging in into Docker Hub";
6 | docker login -u=$DOCKER_USERNAME -p=$DOCKER_PASSWORD;
7 |
8 | echo "Setting Gih user/password";
9 | git config --global user.email "travis@travis-ci.com";
10 | git config --global user.name "Travis-CI";
11 | git config --global push.default upstream;
12 |
13 | REPO_URL="https://$GITHUB_TOKEN@github.com/$TRAVIS_REPO_SLUG.git";
14 | git remote add upstream $REPO_URL
15 |
16 | if [ "$TRAVIS_PULL_REQUEST" == "false" ]; then
17 | if [ "$TRAVIS_BRANCH" == "$RELEASE_BRANCH" ]; then
18 | echo "Release docker container"
19 | ./bin/release.sh -a $DOCKER_HUB_ACCOUNT -t $TRAVIS_BRANCH -l;
20 | fi;
21 |
22 | if [[ "$MAIN_BRANCHES" =~ "$TRAVIS_BRANCH" ]]; then
23 | echo "Done. Commiting changes back to repo.";
24 | git add package.json;
25 | git commit -m "Increment version [ci skip]";
26 | git push upstream HEAD:$TRAVIS_BRANCH > /dev/null 2>&1;
27 | git push upstream HEAD:$TRAVIS_BRANCH --tags > /dev/null 2>&1;
28 | fi;
29 | fi;
30 |
--------------------------------------------------------------------------------
/bin/release.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # This script simplifies releasing a new Docker image of your release.
4 | # It will run following steps:
5 | # 1. Create git tag with version number specified in package.json
6 | # 2. Tag Docker container that is created by build.sh script to a Docker Hub repo.
7 | # 3. Upload changes to Docker Hub.
8 | #
9 | # Usage:
10 | # ./bin/release.sh -a DOCKER_HUB_ACCOUNT_NAME [-v RELEASE_VERSION -l -f]
11 | # '-l' - create additional tag :latest.
12 | # '-f' - force tag creating when git working tree is not empty.
13 |
14 | # Find package.json inside project tree.
15 | # This allows to call bash scripts within any folder inside project.
16 | PROJECT_DIR=$(git rev-parse --show-toplevel)
17 | if [ ! -f "${PROJECT_DIR}/package.json" ]; then
18 | echo "[E] Can't find '${PROJECT_DIR}/package.json'"
19 | echo " Check that you run this script inside git repo or init a new one in project root."
20 | fi
21 |
22 | # Extract project name and version from package.json
23 | PROJECT_NAME=$(cat "${PROJECT_DIR}/package.json" \
24 | | grep name \
25 | | head -1 \
26 | | awk -F: '{ print $2 }' \
27 | | sed 's/[",]//g' \
28 | | tr -d '[[:space:]]')
29 | PROJECT_VERSION=$(cat "${PROJECT_DIR}/package.json" \
30 | | grep version \
31 | | head -1 \
32 | | awk -F: '{ print $2 }' \
33 | | sed 's/[",]//g' \
34 | | tr -d '[[:space:]]')
35 | REPO_TAG=$PROJECT_VERSION
36 |
37 | # A POSIX variable
38 | OPTIND=1 # Reset in case getopts has been used previously in the shell.
39 |
40 | # Default settings
41 | IS_LATEST=0
42 |
43 | if git diff-index --quiet HEAD --; then
44 | PASS_GIT=1
45 | # no changes
46 | else
47 | PASS_GIT=0
48 | fi
49 |
50 | # Parse ARGS
51 | while getopts "v:la:ft:" opt; do
52 | case "$opt" in
53 | a) HUB_ACCOUNT=$OPTARG
54 | ;;
55 | v) PROJECT_VERSION=$OPTARG
56 | ;;
57 | t) REPO_TAG=$OPTARG
58 | ;;
59 | l) IS_LATEST=1
60 | ;;
61 | f) PASS_GIT=1
62 | ;;
63 | esac
64 | done
65 |
66 | if [ ! $HUB_ACCOUNT ]; then
67 | echo "[E] You need to specify Docker Hub account with '-a' option."
68 | exit 1
69 | fi
70 |
71 | # Get release notes
72 | PREVIOUS_TAG=$(git describe HEAD^1 --abbrev=0 --tags)
73 | GIT_HISTORY=$(git log --no-merges --format="- %s" $PREVIOUS_TAG..HEAD)
74 |
75 | if [[ $PREVIOUS_TAG == "" ]]; then
76 | GIT_HISTORY=$(git log --no-merges --format="- %s")
77 | fi;
78 |
79 | # Create git tag that matches release version
80 | if [ `git tag --list $PROJECT_VERSION` ]; then
81 | echo "[W] Git tag '${PROJECT_VERSION}' already exists. I won't be created during release."
82 | else
83 | if [ ! $PASS_GIT ]; then
84 | echo "[E] Working tree contains uncommited changes. This may cause wrong relation between image tag and git tag."
85 | echo " You can skip this check with '-f' option."
86 | exit 1
87 | else
88 | echo "[I] Creating git tag '${PROJECT_VERSION}'.."
89 | echo " Release Notes: "
90 | echo $GIT_HISTORY
91 |
92 | git tag -a $PROJECT_VERSION -m "${GIT_HISTORY}"
93 | fi
94 | fi
95 |
96 | if [ "${REPO_TAG}" != "${PROJECT_VERSION}" ]; then
97 | echo "[I] Tagging image '${PROJECT_NAME}:${PROJECT_VERSION}' into a Docker Hub repository '${HUB_ACCOUNT}/${PROJECT_NAME}:${REPO_TAG}'.."
98 | docker tag "${PROJECT_NAME}:${PROJECT_VERSION}" "${HUB_ACCOUNT}/${PROJECT_NAME}:${REPO_TAG}"
99 | fi
100 |
101 | echo "[I] Tagging image '${PROJECT_NAME}:${PROJECT_VERSION}' into a Docker Hub repository '${HUB_ACCOUNT}/${PROJECT_NAME}:${PROJECT_VERSION}'.."
102 | docker tag "${PROJECT_NAME}:${PROJECT_VERSION}" "${HUB_ACCOUNT}/${PROJECT_NAME}:${PROJECT_VERSION}"
103 |
104 | if [ $IS_LATEST == 1 ]; then
105 | echo "[I] Assigning additional tag '${HUB_ACCOUNT}/${PROJECT_NAME}:latest'.."
106 | docker tag "${PROJECT_NAME}:${PROJECT_VERSION}" "${HUB_ACCOUNT}/${PROJECT_NAME}:latest"
107 | fi
108 |
109 | echo "[I] Pushing changes to Docker Hub.."
110 | docker push "${HUB_ACCOUNT}/${PROJECT_NAME}"
111 |
--------------------------------------------------------------------------------
/bin/start.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # This script starts a local Docker container with created image.
4 |
5 | # Find package.json inside project tree.
6 | # This allows to call bash scripts within any folder inside project.
7 | PROJECT_DIR=$(git rev-parse --show-toplevel)
8 | if [ ! -f "${PROJECT_DIR}/package.json" ]; then
9 | echo "[E] Can't find '${PROJECT_DIR}/package.json'."
10 | echo " Check that you run this script inside git repo or init a new one in project root."
11 | fi
12 |
13 | # Extract project name and version from package.json
14 | PROJECT_NAME=$(cat "${PROJECT_DIR}/package.json" \
15 | | grep name \
16 | | head -1 \
17 | | awk -F: '{ print $2 }' \
18 | | sed 's/[",]//g' \
19 | | tr -d '[[:space:]]')
20 | PROJECT_VERSION=$(cat "${PROJECT_DIR}/package.json" \
21 | | grep version \
22 | | head -1 \
23 | | awk -F: '{ print $2 }' \
24 | | sed 's/[",]//g' \
25 | | tr -d '[[:space:]]')
26 | HOST_IP=`ifconfig | sed -En 's/127.0.0.1//;s/.*inet (addr:)?(([0-9]*\.){3}[0-9]*).*/\2/p' | head -n 1`
27 | HOST_NAME="travis"
28 |
29 | echo "[I] Starting a Docker container '${PROJECT_NAME}' (version '${PROJECT_VERSION}') from path '${PROJECT_DIR}'.."
30 | echo "[I] Assigning parent host '${HOST_NAME}' with IP '${HOST_IP}'."
31 |
32 | echo "${HOST_NAME}:${HOST_IP}"
33 |
34 | docker run -p 8080:8080 \
35 | -d --name ${PROJECT_NAME} \
36 | "${PROJECT_NAME}:${PROJECT_VERSION}"
37 |
--------------------------------------------------------------------------------
/bin/version-increment.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # This script increments patch version number in package.json according to a SEMVER spec.
4 |
5 | # Find package.json inside project tree.
6 | # This allows to call bash scripts within any folder inside project.
7 | PROJECT_DIR=$(git rev-parse --show-toplevel)
8 | if [ ! -f "${PROJECT_DIR}/package.json" ]; then
9 | echo "[E] Can't find '${PROJECT_DIR}/package.json'"
10 | echo " Check that you run this script inside git repo or init a new one in project root."
11 | fi
12 |
13 | # Extract project name and version from package.json
14 | PROJECT_NAME=$(cat "${PROJECT_DIR}/package.json" \
15 | | grep name \
16 | | head -1 \
17 | | awk -F: '{ print $2 }' \
18 | | sed 's/[",]//g' \
19 | | tr -d '[[:space:]]')
20 | PROJECT_VERSION=$(cat "${PROJECT_DIR}/package.json" \
21 | | grep version \
22 | | head -1 \
23 | | awk -F: '{ print $2 }' \
24 | | sed 's/[",]//g' \
25 | | tr -d '[[:space:]]')
26 |
27 | # Increment patch version
28 | # Source: https://github.com/fmahnke/shell-semver/blob/master/increment_version.sh
29 | a=( ${PROJECT_VERSION//./ } )
30 | ((a[2]++))
31 | NEW_PROJECT_VERSION="${a[0]}.${a[1]}.${a[2]}"
32 |
33 | echo "[I] Incrementing project version from '${PROJECT_VERSION}' to '${NEW_PROJECT_VERSION}' in 'package.json'."
34 | sed -i'' -e "s/\"version\": \"${PROJECT_VERSION}\"/\"version\": \"${NEW_PROJECT_VERSION}\"/g" "${PROJECT_DIR}/package.json"
35 | # Here you can modify other files (for eg. README.md) that contains version.
36 |
--------------------------------------------------------------------------------
/cypress.json:
--------------------------------------------------------------------------------
1 | {
2 | "baseUrl": "http://localhost:8080"
3 | }
4 |
--------------------------------------------------------------------------------
/cypress/fixtures/example.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Using fixtures to represent data",
3 | "email": "hello@cypress.io",
4 | "body": "Fixtures are a great way to mock data for responses to routes"
5 | }
--------------------------------------------------------------------------------
/cypress/integration/creating_new_post.js:
--------------------------------------------------------------------------------
1 | describe('Create a new post test', () => {
2 | it('Creating a New Post', () => {
3 | cy.visit('/movies/create');
4 | cy.screenshot();
5 |
6 | cy.get('input[name=title]')
7 | .type('Test title');
8 |
9 | cy.get('input[name=poster]')
10 | .type('https://ia.media-imdb.com/images/M/MV5BMDFkYTc0MGEtZmNhMC00ZDIzLWFmNTEtODM1ZmRlYWMwMWFmXkEyXkFqcGdeQXVyMTMxODk2OTU@._V1_.jpg');
11 |
12 | cy.get('textarea[name=description]')
13 | .type('Test description');
14 |
15 | cy.get('input[name=year]')
16 | .type('Test year');
17 |
18 | cy.get('input[name=director]')
19 | .type('Test director');
20 |
21 | cy.get('button')
22 | .click();
23 | });
24 | });
25 |
--------------------------------------------------------------------------------
/cypress/integration/movies_list.js:
--------------------------------------------------------------------------------
1 | describe('Display movies list', () => {
2 | it('should display list', () => {
3 | cy.visit('/movies');
4 | cy.screenshot();
5 | });
6 | });
7 |
--------------------------------------------------------------------------------
/cypress/plugins/index.js:
--------------------------------------------------------------------------------
1 | // ***********************************************************
2 | // This example plugins/index.js can be used to load plugins
3 | //
4 | // You can change the location of this file or turn off loading
5 | // the plugins file with the 'pluginsFile' configuration option.
6 | //
7 | // You can read more here:
8 | // https://on.cypress.io/plugins-guide
9 | // ***********************************************************
10 |
11 | // This function is called when a project is opened or re-opened (e.g. due to
12 | // the project's config changing)
13 |
14 | module.exports = (on, config) => {
15 | console.log('config ->', config);
16 | // `on` is used to hook into various events Cypress emits
17 | // `config` is the resolved Cypress config
18 | };
19 |
--------------------------------------------------------------------------------
/cypress/support/commands.js:
--------------------------------------------------------------------------------
1 | // ***********************************************
2 | // This example commands.js shows you how to
3 | // create various custom commands and overwrite
4 | // existing commands.
5 | //
6 | // For more comprehensive examples of custom
7 | // commands please read more here:
8 | // https://on.cypress.io/custom-commands
9 | // ***********************************************
10 | //
11 | //
12 | // -- This is a parent command --
13 | // Cypress.Commands.add("login", (email, password) => { ... })
14 | //
15 | //
16 | // -- This is a child command --
17 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... })
18 | //
19 | //
20 | // -- This is a dual command --
21 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... })
22 | //
23 | //
24 | // -- This is will overwrite an existing command --
25 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
26 |
27 | Cypress.Screenshot.defaults({
28 | capture: 'fullPage',
29 | });
30 |
--------------------------------------------------------------------------------
/cypress/support/index.js:
--------------------------------------------------------------------------------
1 | // ***********************************************************
2 | // This example support/index.js is processed and
3 | // loaded automatically before your test files.
4 | //
5 | // This is a great place to put global configuration and
6 | // behavior that modifies Cypress.
7 | //
8 | // You can change the location of this file or turn off
9 | // automatically serving support files with the
10 | // 'supportFile' configuration option.
11 | //
12 | // You can read more here:
13 | // https://on.cypress.io/configuration
14 | // ***********************************************************
15 |
16 | // Import commands.js using ES2015 syntax:
17 | import './commands';
18 |
19 | // Alternatively you can use CommonJS syntax:
20 | // require('./commands')
21 |
--------------------------------------------------------------------------------
/docs/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FrontBand/react-boilerplate/f76ddc9b93d804180e908bb0ff47825108f05da5/docs/.gitkeep
--------------------------------------------------------------------------------
/docs/images/atom-eslint-autofix.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FrontBand/react-boilerplate/f76ddc9b93d804180e908bb0ff47825108f05da5/docs/images/atom-eslint-autofix.png
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-boilerplate",
3 | "version": "0.1.0",
4 | "description": "FrontBand. React boilerplate",
5 | "repository": "https://github.com/FrontBand/react-boilerplate.git",
6 | "scripts": {
7 | "postinstall": "[ $PREBUILD ] && npm run build || exit 0",
8 | "predev": "npm run webpack",
9 | "dev": "concurrently --kill-others \"node static/server.js\" \"node webpack.server.js\"",
10 | "stats": "NODE_ENV=production webpack-dashboard -- node webpack.server.js",
11 | "start": "NODE_ENV=production node --inspect static/server.js",
12 | "webpack": "webpack",
13 | "build": "NODE_ENV=production npm run webpack",
14 | "preproduction": "npm run build",
15 | "production": "NODE_ENV=production node --inspect static/server.js",
16 | "hooks:commit": "lint-staged",
17 | "stylelint": "stylelint 'app/**/*.scss' 'app/**/*.css' 'assets/**/*.scss' 'assets/**/*.css'",
18 | "lint": "eslint app tests && npm run stylelint",
19 | "locales:extract": "i18next-extract-gettext --files='./+(app)/**/*.+(js|json)' --output=app/common/locales/source.pot",
20 | "storybook": "start-storybook -p 9001 -c .storybook",
21 | "test": "jest",
22 | "test:acceptance": "cypress run --browser chrome"
23 | },
24 | "lint-staged": {
25 | "*.js": "eslint app",
26 | "*.@(scss|css)": "stylelint"
27 | },
28 | "config": {
29 | "ghooks": {
30 | "pre-commit": "npm run hooks:commit"
31 | }
32 | },
33 | "browserslist": [
34 | "defaults",
35 | "> 1%",
36 | "iOS >= 7",
37 | "Safari >= 7",
38 | "Safari 8"
39 | ],
40 | "engines": {
41 | "node": ">=7.5.0"
42 | },
43 | "author": "Front.Band (https://front.band/)",
44 | "license": "MIT",
45 | "dependencies": {
46 | "assets-webpack-plugin": "^3.4.0",
47 | "autoprefixer": "^7.1.2",
48 | "babel": "^6.23.0",
49 | "babel-core": "^6.26.3",
50 | "babel-loader": "^7.1.1",
51 | "babel-plugin-module-resolver": "^2.7.0",
52 | "babel-plugin-react-transform": "^2.0.2",
53 | "babel-plugin-transform-decorators-legacy": "^1.3.4",
54 | "babel-plugin-transform-runtime": "^6.23.0",
55 | "babel-polyfill": "^6.23.0",
56 | "babel-preset-es2015": "^6.24.0",
57 | "babel-preset-react": "^6.23.0",
58 | "babel-preset-stage-0": "^6.22.0",
59 | "babel-register": "^6.24.0",
60 | "chai-spies": "^0.7.1",
61 | "classnames": "^2.2.5",
62 | "cookie-dough": "^0.1.0",
63 | "cookie-parser": "^1.4.3",
64 | "core-js": "^2.4.1",
65 | "cross-env": "^2.0.0",
66 | "css-loader": "^0.28.4",
67 | "cypress": "^3.0.1",
68 | "date-fns": "^2.0.0-alpha.1",
69 | "dotenv": "^2.0.0",
70 | "ejs": "^2.4.2",
71 | "eslint-plugin-cypress": "^2.0.1",
72 | "express": "^4.14.0",
73 | "extract-text-webpack-plugin": "^2.1.0",
74 | "file-loader": "^0.11.2",
75 | "history": "^3.0.0",
76 | "i18next": "^8.4.3",
77 | "i18next-browser-languagedetector": "^2.0.0",
78 | "i18next-express-middleware": "^1.0.5",
79 | "i18next-po-loader": "^1.0.0",
80 | "ignore-styles": "^5.0.1",
81 | "isomorphic-style-loader": "^4.0.0",
82 | "json-loader": "^0.5.5",
83 | "lodash": "^4.16.0",
84 | "node-cron": "^1.2.0",
85 | "normalizr": "^3.2.3",
86 | "path": "0.12.7",
87 | "postcss": "^6.0.4",
88 | "postcss-apply": "^0.7.0",
89 | "postcss-css-variables": "^0.7.0",
90 | "postcss-import": "^10.0.0",
91 | "postcss-loader": "^2.0.6",
92 | "postcss-math": "^0.0.8",
93 | "postcss-nested": "^2.0.1",
94 | "precss": "^2.0.0",
95 | "prop-types": "^15.6.1",
96 | "proxy-middleware": "^0.15.0",
97 | "react": "^16.3.0",
98 | "react-dom": "^16.3.2",
99 | "react-helmet": "^5.1.3",
100 | "react-hot-loader": "^3.0.0-beta.6",
101 | "react-i18next": "4.3.0",
102 | "react-nebo15-validate": "^0.1.12",
103 | "react-redux": "^5.0.5",
104 | "react-router": "^3.0.0",
105 | "react-router-redial": "^0.3.4",
106 | "react-router-redux": "^4.0.6",
107 | "react-router-sitemap": "^1.1.1",
108 | "react-svg-loader": "^1.1.1",
109 | "react-svg-sprite-icon": "^0.0.11",
110 | "recompose": "^0.27.0",
111 | "redial": "^0.5.0",
112 | "redux": "^3.7.1",
113 | "redux-actions": "^2.1.0",
114 | "redux-api-middleware": "^1.0.3",
115 | "redux-form": "^7.0.0",
116 | "redux-freeze": "^0.1.4",
117 | "redux-multi": "^0.1.12",
118 | "redux-promise": "^0.5.3",
119 | "redux-thunk": "^2.1.0",
120 | "style-loader": "^0.18.2",
121 | "svg-inline-loader": "^0.8.0",
122 | "svg-inline-react": "^1.0.2",
123 | "uuid": "^3.2.1",
124 | "webfonts-loader": "^1.2.0",
125 | "webpack": "^3.1.0",
126 | "webpack-merge": "^4.1.0"
127 | },
128 | "devDependencies": {
129 | "@storybook/addon-links": "^3.4.2",
130 | "@storybook/addon-options": "^3.4.2",
131 | "@storybook/channels": "^3.4.2",
132 | "@storybook/react": "^3.4.2",
133 | "argos-cli": "^0.0.9",
134 | "babel-eslint": "^6.1.2",
135 | "babel-istanbul-instrumenter-loader": "^1.0.1",
136 | "babel-jest": "^23.0.1",
137 | "babel-preset-react-hmre": "^1.1.1",
138 | "browserstack-local": "^1.2.0",
139 | "chai": "^4.1.0",
140 | "chromedriver": "^2.29.2",
141 | "concurrently": "^3.5.0",
142 | "coveralls": "^2.13.1",
143 | "enzyme": "^2.9.1",
144 | "eslint": "^3.6.0",
145 | "eslint-config-airbnb": "^11.2.0",
146 | "eslint-import-resolver-babel-module": "^2.2.1",
147 | "eslint-plugin-chai-expect": "^1.1.1",
148 | "eslint-plugin-import": "^2.3.0",
149 | "eslint-plugin-jsx-a11y": "^2.2.2",
150 | "eslint-plugin-react": "^6.3.0",
151 | "ghooks": "^1.3.2",
152 | "i18next-extract-gettext": "^3.1.3",
153 | "isparta-loader": "^2.0.0",
154 | "jest": "^23.1.0",
155 | "lint-staged": "^4.0.1",
156 | "react-addons-test-utils": "^15.2.1",
157 | "regenerator-runtime": "^0.11.1",
158 | "stylelint": "~9.2.0",
159 | "stylelint-config-standard": "~18.2.0",
160 | "webpack-dashboard": "^0.4.0",
161 | "webpack-dev-server": "^2.5.1"
162 | }
163 | }
164 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | const autoprefixer = require('autoprefixer');
2 | const precss = require('precss');
3 | const postCssNested = require('postcss-nested');
4 | const postCssApply = require('postcss-apply');
5 | const postCssVariables = require('postcss-css-variables');
6 | const postCssImport = require('postcss-import');
7 | const postCssMath = require('postcss-math');
8 | const postCssExtend = require('postcss-extend');
9 | const cssNano = require('cssnano');
10 |
11 | const DEBUG = process.env.NODE_ENV !== 'production';
12 |
13 | module.exports = {
14 | plugins: [
15 | postCssImport({
16 | path: [
17 | 'app/common/styles',
18 | ],
19 | }),
20 | precss,
21 | postCssNested,
22 | postCssVariables,
23 | postCssMath,
24 | postCssApply,
25 | postCssExtend,
26 | autoprefixer,
27 | ].concat(DEBUG ? [] : [
28 | cssNano({
29 | autoprefixer: false,
30 | reduceInitial: false,
31 | discardComments: {
32 | removeAll: true,
33 | },
34 | discardEmpty: true,
35 | discardUnused: false,
36 | mergeIdents: false,
37 | normalizeUrl: false,
38 | reduceIdents: false,
39 | zindex: false,
40 | }),
41 | ]),
42 | };
43 |
--------------------------------------------------------------------------------
/public/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FrontBand/react-boilerplate/f76ddc9b93d804180e908bb0ff47825108f05da5/public/.gitkeep
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | require('dotenv').config({ silent: true });
2 |
3 | const path = require('path');
4 | const webpack = require('webpack');
5 | const webpackMerge = require('webpack-merge');
6 | const AssetsPlugin = require('assets-webpack-plugin');
7 |
8 | const parts = require('./webpack/parts');
9 |
10 | const DEBUG = process.env.NODE_ENV !== 'production';
11 |
12 | const genConfig = webpackMerge(
13 | {
14 | devtool: DEBUG ? 'eval' : false,
15 | plugins: [
16 | new webpack.DefinePlugin({
17 | __DEV__: DEBUG,
18 | }),
19 | ],
20 | },
21 | parts.setupJs(),
22 | parts.setupFont(),
23 | parts.setupImages(),
24 | parts.setupJson(),
25 | parts.setupI18n(),
26 | !DEBUG && parts.setupProduction()
27 | );
28 |
29 | const config = webpackMerge(
30 | {
31 | entry: {
32 | app: ['babel-polyfill', './app/client'],
33 | },
34 | output: {
35 | path: path.join(__dirname, 'static'),
36 | publicPath: '/static/',
37 | filename: '[name].js?[hash]',
38 | },
39 | plugins: [
40 | new webpack.DefinePlugin({
41 | 'process.env': {
42 | NODE_ENV: JSON.stringify(process.env.NODE_ENV || 'development'),
43 | },
44 | __CLIENT__: true,
45 | }),
46 | new AssetsPlugin({
47 | path: path.join(__dirname, 'static'),
48 | }),
49 | ],
50 | },
51 | DEBUG ? parts.setupCss() : parts.setupCssExtract(),
52 | DEBUG ? parts.setupFontGen() : parts.setupFontGenExtract(),
53 | genConfig
54 | );
55 |
56 | const serverConfig = webpackMerge(
57 | {
58 | entry: {
59 | server: './app/server/server.js',
60 | },
61 | target: 'node',
62 | node: {
63 | __dirname: true,
64 | __filename: true,
65 | },
66 | output: {
67 | path: path.join(__dirname, 'static'),
68 | filename: '[name].js',
69 | publicPath: '/static/',
70 | libraryTarget: 'commonjs2',
71 | },
72 |
73 | plugins: [
74 | new webpack.DefinePlugin({
75 | __CLIENT__: false,
76 | }),
77 | ],
78 | externals: [
79 | /^[a-z\/\-0-9]+$/i,
80 | ],
81 | },
82 | parts.setupCssCritical(),
83 | parts.setupFontGenCritical(),
84 | genConfig
85 | );
86 |
87 | module.exports = [
88 | DEBUG ? parts.setupHotReload(config) : config,
89 | serverConfig,
90 | ];
91 |
--------------------------------------------------------------------------------
/webpack.server.js:
--------------------------------------------------------------------------------
1 | const webpack = require('webpack');
2 | const WebpackDevServer = require('webpack-dev-server');
3 | const DashboardPlugin = require('webpack-dashboard/plugin');
4 |
5 | const config = require('./webpack.config');
6 |
7 | const port = 3030;
8 |
9 | const compiler = webpack(config[0]);
10 | compiler.apply(new DashboardPlugin());
11 |
12 | new WebpackDevServer(compiler, {
13 | publicPath: config[0].output.publicPath,
14 | hot: true,
15 | headers: { 'Access-Control-Allow-Origin': '*' },
16 | // It suppress error shown in console, so it has to be set to false.
17 | quiet: false,
18 | // It suppress everything except error, so it has to be set to false as well
19 | // to see success build.
20 | noInfo: false,
21 | stats: {
22 | // Config for minimal console.log mess.
23 | assets: false,
24 | colors: true,
25 | version: false,
26 | hash: false,
27 | timings: false,
28 | chunks: false,
29 | chunkModules: false,
30 | },
31 | }).listen(port, 'localhost', (err) => {
32 | /* eslint-disable no-console */
33 | if (err) {
34 | console.log(err);
35 | }
36 |
37 | console.log(`Webpack dev server is listening at localhost: ${port}`);
38 | });
39 |
--------------------------------------------------------------------------------
/webpack/parts.js:
--------------------------------------------------------------------------------
1 | const webpack = require('webpack');
2 | const webpackMerge = require('webpack-merge');
3 |
4 | const ExtractTextPlugin = require('extract-text-webpack-plugin');
5 |
6 | const DEBUG = process.env.NODE_ENV !== 'production';
7 |
8 | const extractStyles = new ExtractTextPlugin('[name].css?[hash]');
9 |
10 | exports.setupJs = () => ({
11 | module: {
12 | rules: [
13 | {
14 | test: /\.js$/,
15 | use: ['babel-loader'],
16 | exclude: /node_modules/,
17 | },
18 | ],
19 | },
20 | });
21 |
22 |
23 | const cssLoader = {
24 | loader: 'css-loader',
25 | options: {
26 | localIdentName: DEBUG ? '[local]__[path][name]__[hash:base64:5]' : '[hash:base64]',
27 | modules: true,
28 | // it doesn't work correctly. It uses cssnano for minification, but do It unsafe.
29 | // For example, It remove -webkit prefix from flex rules. And it breaks support of Safari 8
30 | minimize: false,
31 | },
32 | };
33 |
34 | const scssLoaders = [
35 | cssLoader,
36 | 'postcss-loader',
37 | ];
38 |
39 | const cssLoaders = [
40 | 'css-loader',
41 | ];
42 |
43 | const fontLoaders = [
44 | cssLoader,
45 | {
46 | loader: 'webfonts-loader',
47 | options: {
48 | embed: true,
49 | },
50 | },
51 | ];
52 |
53 | exports.setupCssCritical = () => ({
54 | module: {
55 | rules: [
56 | {
57 | test: /\.scss/,
58 | use: [
59 | 'isomorphic-style-loader',
60 | ].concat(scssLoaders),
61 | },
62 | {
63 | test: /\.css/,
64 | use: [
65 | 'isomorphic-style-loader',
66 | ].concat(cssLoaders),
67 | },
68 | ],
69 | },
70 | });
71 |
72 | exports.setupCss = () => ({
73 | module: {
74 | rules: [
75 | {
76 | test: /\.scss/,
77 | use: [
78 | 'style-loader',
79 | ...scssLoaders,
80 | ],
81 | },
82 | {
83 | test: /\.css/,
84 | use: [
85 | 'style-loader',
86 | ...cssLoaders,
87 | ],
88 | },
89 | ],
90 | },
91 | });
92 |
93 | exports.setupCssExtract = () => ({
94 | module: {
95 | rules: [
96 | {
97 | test: /\.scss/,
98 | use: extractStyles.extract({
99 | use: scssLoaders,
100 | }),
101 | },
102 | {
103 | test: /\.css/,
104 | use: extractStyles.extract({
105 | use: cssLoaders,
106 | }),
107 | },
108 | ],
109 | },
110 | plugins: [
111 | extractStyles,
112 | ],
113 | });
114 |
115 | exports.setupCssIgnore = () => ({
116 | module: {
117 | rules: [
118 | {
119 | test: /\.(scss|css)/,
120 | use: [
121 | 'ignore-loader',
122 | ],
123 | },
124 | ],
125 | },
126 | });
127 |
128 | exports.setupFontGen = () => ({
129 | module: {
130 | rules: [
131 | {
132 | test: /\.font\.(js|json)$/,
133 | use: [
134 | 'style-loader',
135 | ].concat(fontLoaders),
136 | },
137 | ],
138 | },
139 | });
140 |
141 | exports.setupFontGenCritical = () => ({
142 | module: {
143 | rules: [
144 | {
145 | test: /\.font\.(js|json)$/,
146 | use: [
147 | 'isomorphic-style-loader',
148 | ].concat(fontLoaders),
149 | },
150 | ],
151 | },
152 | });
153 |
154 | exports.setupFontGenExtract = () => ({
155 | module: {
156 | rules: [
157 | {
158 | test: /\.font\.(js|json)$/,
159 | use: extractStyles.extract({
160 | use: fontLoaders,
161 | }),
162 | },
163 | ],
164 | },
165 | plugins: [
166 | extractStyles,
167 | ],
168 | });
169 |
170 | exports.setupFont = () => ({
171 | module: {
172 | rules: [
173 | {
174 | test: /\.(woff|woff2|eot|ttf)(\?.*$|$)/,
175 | loader: 'file-loader',
176 | },
177 | ],
178 | },
179 | });
180 |
181 | exports.setupImages = () => ({
182 | module: {
183 | rules: [
184 | {
185 | test: /.*\.(gif|png|svg|jpe?g)$/i,
186 | use: [
187 | {
188 | loader: 'file-loader',
189 | options: {
190 | name: '[hash].[ext]',
191 | },
192 | },
193 | ],
194 | },
195 | ],
196 | },
197 | });
198 |
199 | exports.setupJson = () => ({
200 | module: {
201 | rules: [
202 | {
203 | test: /\.json/i,
204 | loader: 'json-loader',
205 | },
206 | ],
207 | },
208 | });
209 |
210 | exports.setupI18n = () => ({
211 | module: {
212 | rules: [
213 | {
214 | test: /\.po$/,
215 | use: [
216 | 'i18next-po-loader',
217 | ],
218 | },
219 | ],
220 | },
221 | });
222 |
223 | exports.setupProduction = () => ({
224 | plugins: [
225 | new webpack.LoaderOptionsPlugin({
226 | minimize: true,
227 | debug: false,
228 | }),
229 | new webpack.optimize.UglifyJsPlugin({
230 | compress: {
231 | warnings: false,
232 | screw_ie8: true,
233 | conditionals: true,
234 | unused: true,
235 | comparisons: true,
236 | sequences: true,
237 | dead_code: true,
238 | evaluate: true,
239 | if_return: true,
240 | join_vars: true,
241 | },
242 | output: {
243 | comments: false,
244 | },
245 | }),
246 | ],
247 | });
248 |
249 | exports.setupHotReload = (config, port = 3030) => {
250 | const resConfig = webpackMerge(
251 | {},
252 | config, {
253 | output: {
254 | publicPath: `http://localhost:${port}${config.output.publicPath}`,
255 | },
256 | plugins: [
257 | new webpack.HotModuleReplacementPlugin(),
258 | new webpack.NamedModulesPlugin(),
259 | ],
260 | }
261 | );
262 |
263 | Object.keys(config.entry).forEach((key) => {
264 | resConfig.entry[key] = [
265 | 'react-hot-loader/patch',
266 | `webpack-dev-server/client?http://localhost:${port}`, // WebpackDevServer host and port
267 | 'webpack/hot/only-dev-server',
268 | ].concat(config.entry[key]);
269 | });
270 |
271 | return resConfig;
272 | };
273 |
--------------------------------------------------------------------------------