├── .editorconfig ├── .eslintrc ├── .flowconfig ├── .gitignore ├── .prettierrc ├── .stylelintrc ├── Dockerfile ├── LICENSE ├── README.md ├── babel.config.js ├── circle.yml ├── docker-compose.yml ├── env.js ├── jest.config.js ├── package.json ├── postcss.config.js ├── src ├── App.js ├── actions.js ├── assets │ ├── datas │ │ └── manifest.json │ ├── fonts │ │ └── .gitkeep │ ├── images │ │ ├── favicon.ico │ │ ├── icon-144x144.png │ │ └── react.png │ ├── medias │ │ └── .gitkeep │ └── styles │ │ ├── _medias.css │ │ ├── _root.css │ │ ├── _selectors.css │ │ └── global.css ├── constants.js ├── core │ ├── Router.js │ ├── i18n │ │ ├── en.js │ │ ├── index.js │ │ ├── ja.js │ │ ├── ko.js │ │ └── zh.js │ ├── material.js │ ├── request.js │ ├── service-worker.js │ └── store.js ├── home │ └── Home.js ├── index.html ├── main.js ├── not-found │ └── NotFound.js ├── reducer.js ├── selectors.js ├── shared │ ├── assign-deep.js │ ├── compose.js │ └── nest-pairs.js └── shell │ ├── RecursiveList.js │ ├── SortFilterList.js │ ├── crud-operations │ ├── basic │ │ ├── Basic.js │ │ ├── actions.js │ │ ├── constants.js │ │ ├── reducer.js │ │ └── selectors.js │ └── reducer.js │ ├── github-repos │ ├── GithubRepos.js │ └── InfiniteScroll.js │ ├── hello-world │ ├── HelloWorld.js │ ├── __tests__ │ │ ├── HelloWorld.e2e-spec.js │ │ ├── HelloWorld.spec.js │ │ └── __snapshots__ │ │ │ └── HelloWorld.spec.js.snap │ ├── constants.js │ └── reducer.js │ └── markdown-editor │ ├── ArticleDetail.js │ ├── MarkdownEditor.js │ ├── actions.js │ ├── constants.js │ └── reducer.js ├── tools ├── assets-transform.js ├── dev.Dockerfile ├── prod.Dockerfile ├── release.sh ├── setup-app.js ├── setup-e2e.js └── stage.Dockerfile ├── webpack.config.js └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_size = 2 6 | indent_style = space 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "prettier" 4 | ], 5 | "env": { 6 | "browser": true, 7 | "node": true, 8 | "commonjs": true, 9 | "es6": true, 10 | "jest": true 11 | }, 12 | "globals": {}, 13 | "extends": [ 14 | "airbnb", 15 | "prettier", 16 | "prettier/react" 17 | ], 18 | "parser": "babel-eslint", 19 | "parserOptions": { 20 | "sourceType": "module" 21 | }, 22 | "settings": { 23 | "import/resolver": { 24 | "webpack": { 25 | "config": "webpack.config.js" 26 | } 27 | } 28 | }, 29 | "rules": { 30 | // Plugins 31 | "import/no-named-as-default": "off", 32 | "import/prefer-default-export": "off", 33 | 34 | "prettier/prettier": "off", 35 | 36 | "react/jsx-filename-extension": ["warn", { "extensions": [".js", ".jsx"] }], 37 | "react/jsx-one-expression-per-line": "off", 38 | "react/prop-types": "off", 39 | 40 | // Possible Errors 41 | 42 | // Best Practices 43 | "no-multi-spaces": ["error", { "ignoreEOLComments": true }], 44 | 45 | // Variables 46 | "no-shadow": "off", 47 | 48 | // Node.js and CommonJS 49 | 50 | // Stylistic Issues 51 | "function-paren-newline": "off", 52 | "max-len": "off", 53 | "no-plusplus": ["error", { "allowForLoopAfterthoughts": true }], 54 | "no-underscore-dangle": "off", 55 | "object-curly-newline": ["error", { "consistent": true }], 56 | 57 | // JS.Next 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [include] 2 | 3 | [ignore] 4 | .*/node_modules/* 5 | .*\.spec\.js 6 | .*\.e2e-spec\.js 7 | 8 | [libs] 9 | flow-typed/ 10 | 11 | [lints] 12 | 13 | [options] 14 | include_warnings=true 15 | module.name_mapper='^~/\(.*\)$' -> '/src/\1' 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | npm 4 | public 5 | coverage 6 | *.log 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "singleQuote": true, 4 | "trailingComma": "all" 5 | } 6 | -------------------------------------------------------------------------------- /.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "stylelint-config-standard", 3 | "rules": { 4 | "declaration-empty-line-before": null, 5 | "no-empty-source": null 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:12 2 | 3 | ENV HOME /React-Play 4 | 5 | WORKDIR ${HOME} 6 | ADD . $HOME 7 | 8 | # chrome -- 9 | ENV CHROME_BIN /usr/bin/chromium 10 | ENV DISPLAY :99 11 | 12 | RUN \ 13 | apt-get update && \ 14 | apt-get install -y xvfb chromium libgconf-2-4 15 | 16 | ENTRYPOINT ["Xvfb", "-ac", ":99", "-screen", "0", "1280x800x16"] 17 | # -- chrome 18 | 19 | # puppeteer -- 20 | RUN \ 21 | apt-get update && apt-get install -y wget --no-install-recommends && \ 22 | wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - && \ 23 | sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' && \ 24 | apt-get update && \ 25 | apt-get install -y google-chrome-unstable --no-install-recommends && \ 26 | apt-get purge --auto-remove -y curl 27 | # -- puppeteer 28 | 29 | RUN \ 30 | rm -rf /var/lib/apt/lists/* && \ 31 | rm -rf /src/*.deb 32 | 33 | RUN yarn install 34 | 35 | EXPOSE 8000 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Shyam Chen 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 Starter 2 | 3 | :ram: A boilerplate for React, Material, Babel, Flow, and ReactiveX. 4 | 5 | ## Table of Contents 6 | 7 | - [Project Setup](#project-setup) 8 | - [Key Features](#key-features) 9 | - [Dockerization](#dockerization) 10 | - [Configuration](#configuration) 11 | - [Directory Structure](#directory-structure) 12 | 13 | ## Project Setup 14 | 15 | Follow steps to execute this boilerplate. 16 | 17 | ### Install dependencies 18 | 19 | ```sh 20 | $ yarn install 21 | ``` 22 | 23 | ### Compiles and hot-reloads for development 24 | 25 | ```sh 26 | $ yarn serve 27 | ``` 28 | 29 | ### Compiles and minifies for production 30 | 31 | ```sh 32 | $ yarn build 33 | ``` 34 | 35 | ### Lints and fixes files 36 | 37 | ```sh 38 | $ yarn lint 39 | ``` 40 | 41 | ### Runs unit tests 42 | 43 | Files: `src/**/*.spec.js` 44 | 45 | ```sh 46 | $ yarn unit 47 | ``` 48 | 49 | ### Runs end-to-end tests 50 | 51 | Files: `e2e/**/*.spec.js` 52 | 53 | ```sh 54 | # Before running the `e2e` command, make sure to run the following commands. 55 | $ yarn build 56 | $ yarn preview 57 | 58 | # If it's not setup, run it. 59 | $ yarn setup 60 | 61 | $ yarn e2e 62 | ``` 63 | 64 | ### Measures site's URLs 65 | 66 | Files: `e2e/**/*.meas.js` 67 | 68 | ```sh 69 | # Before running the `meas` command, make sure to run the following commands. 70 | $ yarn build 71 | $ yarn preview 72 | 73 | # If it's not setup, run it. 74 | $ yarn setup 75 | 76 | $ yarn meas 77 | ``` 78 | 79 | ### Mock requests 80 | 81 | [`mock/requests`](./mock/requests) is a fork of [Koa-Starter](https://github.com/Shyam-Chen/Koa-Starter) that was made easy and quick way to run mock APIs locally. 82 | 83 | ```sh 84 | # If it's not active, run it. 85 | $ yarn active 86 | 87 | $ yarn mock 88 | ``` 89 | 90 | ## Key Features 91 | 92 | This seed repository provides the following features: 93 | 94 | - ---------- **Essentials** ---------- 95 | - [x] [React](https://github.com/facebook/react) 96 | - [x] [React Router](https://github.com/ReactTraining/react-router) 97 | - [ ] [React Intl](https://github.com/yahoo/react-intl) 98 | - [x] [Recompose](https://github.com/acdlite/recompose) 99 | - [x] [Redux](https://github.com/reduxjs/redux) 100 | - [x] [Redux Thunk](https://github.com/reduxjs/redux-thunk) 101 | - [x] [Reselect](https://github.com/reduxjs/reselect) 102 | - [x] [Material UI](https://github.com/mui-org/material-ui) 103 | - [x] [Axios](https://github.com/axios/axios) 104 | - [ ] [Apollo GraphQL](https://github.com/apollographql) 105 | - [x] [ReactiveX](https://github.com/ReactiveX/rxjs) 106 | - ---------- **Tools** ---------- 107 | - [x] [Webpack](https://github.com/webpack/webpack) 108 | - [x] [JSX](https://github.com/facebook/jsx) 109 | - [x] [JSS](https://github.com/cssinjs/jss) 110 | - [x] [Puppeteer](https://github.com/GoogleChrome/puppeteer) 111 | - [x] [Babel](https://github.com/babel/babel) 112 | - [x] [ESLint](https://github.com/eslint/eslint) 113 | - [x] [Jest](https://github.com/facebook/jest) 114 | - ---------- **Environments** ---------- 115 | - [x] [Node.js](https://nodejs.org/en/) 116 | - [x] [Yarn](https://classic.yarnpkg.com/lang/en/) 117 | - [ ] [Caddy](https://caddyserver.com/) 118 | - [ ] [Netlify](https://www.netlify.com/) 119 | 120 | ## Dockerization 121 | 122 | Dockerize an application. 123 | 124 | 1. Build and run the container in the background 125 | 126 | ```bash 127 | $ docker-compose up -d 128 | ``` 129 | 130 | 2. Run a command in a running container 131 | 132 | ```bash 133 | $ docker-compose exec 134 | ``` 135 | 136 | 3. Remove the old container before creating the new one 137 | 138 | ```bash 139 | $ docker-compose rm -fs 140 | ``` 141 | 142 | 4. Restart up the container in the background 143 | 144 | ```bash 145 | $ docker-compose up -d --build 146 | ``` 147 | 148 | 5. Push images to Docker Cloud 149 | 150 | ```diff 151 | # .gitignore 152 | 153 | .DS_Store 154 | node_modules 155 | npm 156 | public 157 | functions 158 | coverage 159 | + dev.Dockerfile 160 | + stage.Dockerfile 161 | + prod.Dockerfile 162 | *.log 163 | ``` 164 | 165 | ```bash 166 | $ docker login 167 | $ docker build -f ./tools/.Dockerfile -t : . 168 | 169 | # checkout 170 | $ docker images 171 | 172 | $ docker tag : /: 173 | $ docker push /: 174 | 175 | # remove 176 | $ docker rmi : 177 | # or 178 | $ docker rmi 179 | ``` 180 | 181 | 6. Pull images from Docker Cloud 182 | 183 | ```diff 184 | # docker-compose.yml 185 | 186 | : 187 | - image: 188 | - build: 189 | - context: . 190 | - dockerfile: ./tools/.Dockerfile 191 | + image: /: 192 | volumes: 193 | - yarn:/home/node/.cache/yarn 194 | tty: true 195 | ``` 196 | 197 | ## Configuration 198 | 199 | ### Project environments 200 | 201 | Change to your project. 202 | 203 | ```js 204 | // .firebaserc 205 | { 206 | "projects": { 207 | "development": "", 208 | "staging": "", 209 | "production": "" 210 | } 211 | } 212 | ``` 213 | 214 | ### Default environments 215 | 216 | Set your local environment variables. (use `this. = process.env. || ;`) 217 | 218 | ```js 219 | // env.js 220 | function Environments() { 221 | this.NODE_ENV = process.env.NODE_ENV || 'development'; 222 | 223 | this.PROJECT_NAME = process.env.PROJECT_NAME || ''; 224 | 225 | this.HOST_NAME = process.env.HOST_NAME || '0.0.0.0'; 226 | 227 | this.SITE_PORT = process.env.SITE_PORT || 8000; 228 | this.SITE_URL = process.env.SITE_URL || `http://${this.HOST_NAME}:${this.SITE_PORT}`; 229 | 230 | this.APP_BASE = process.env.APP_BASE || '/'; 231 | 232 | this.GOOGLE_ANALYTICS = process.env.GOOGLE_ANALYTICS || ''; 233 | 234 | this.SENTRY_DSN = process.env.SENTRY_DSN || null; 235 | this.RENDERTRON_URL = process.env.RENDERTRON_URL || null; 236 | } 237 | ``` 238 | 239 | ### Deployment environment 240 | 241 | Set your deployment environment variables. 242 | 243 | ```dockerfile 244 | # tools/.Dockerfile 245 | 246 | # envs -- 247 | ENV SITE_URL 248 | ENV FUNC_URL 249 | # -- envs 250 | ``` 251 | 252 | ### CI environment 253 | 254 | Add environment variables to the CircleCI build. 255 | 256 | ```yml 257 | CODECOV_TOKEN 258 | DOCKER_PASSWORD 259 | DOCKER_USERNAME 260 | FIREBASE_TOKEN 261 | ``` 262 | 263 | ### SEO friendly 264 | 265 | Enable billing on your Firebase Platform and Google Cloud the project by switching to the Blaze plan. 266 | 267 | Serve dynamic content for bots. 268 | 269 | ```diff 270 | // firebase.json 271 | "rewrites": [ 272 | { 273 | "source": "**", 274 | - "destination": "/index.html" 275 | + "function": "app" 276 | } 277 | ], 278 | ``` 279 | 280 | Deploy rendertron instance to Google App Engine. 281 | 282 | ```bash 283 | $ git clone https://github.com/GoogleChrome/rendertron 284 | $ cd rendertron 285 | $ gcloud auth login 286 | $ gcloud app deploy app.yaml --project 287 | ``` 288 | 289 | Set your rendertron instance in deployment environment. 290 | 291 | ```dockerfile 292 | # tools/.Dockerfile 293 | 294 | # envs -- 295 | ENV RENDERTRON_URL 296 | # -- envs 297 | ``` 298 | 299 | ### VS Code settings 300 | 301 | The most basic configuration. 302 | 303 | ```js 304 | { 305 | "window.zoomLevel": 1, 306 | "workbench.colorTheme": "Material Theme", 307 | "workbench.iconTheme": "material-icon-theme", 308 | "eslint.validate": [ 309 | "javascript", { 310 | "language": "vue" 311 | }, 312 | "javascriptreact", 313 | "html" 314 | ], 315 | "javascript.validate.enable": false, 316 | "vetur.validation.script": false 317 | } 318 | ``` 319 | 320 | ## Directory Structure 321 | 322 | The structure follows the LIFT Guidelines. 323 | 324 | ```coffee 325 | . 326 | ├── src 327 | │ ├── api 328 | │ │ ├── __tests__ 329 | │ │ │ └── ... 330 | │ │ ├── _ -> api of private things 331 | │ │ │ └── ... 332 | │ │ ├── core -> core feature module 333 | │ │ │ └── ... 334 | │ │ ├── -> feature modules 335 | │ │ │ ├── __tests__ 336 | │ │ │ │ └── ... 337 | │ │ │ ├── _ -> feature of private things 338 | │ │ │ │ └── ... 339 | │ │ │ └── ... 340 | │ │ ├── -> module group 341 | │ │ │ └── -> feature modules 342 | │ │ │ ├── __tests__ 343 | │ │ │ │ └── ... 344 | │ │ │ ├── _ -> feature of private things 345 | │ │ │ │ └── ... 346 | │ │ │ └── ... 347 | │ │ ├── graphql 348 | │ │ │ ├── -> feature modules 349 | │ │ │ │ ├── __tests__ 350 | │ │ │ │ │ └── ... 351 | │ │ │ │ ├── _ -> feature of private things 352 | │ │ │ │ │ └── ... 353 | │ │ │ │ └── ... 354 | │ │ │ └── -> module group 355 | │ │ │ └── -> feature modules 356 | │ │ │ ├── __tests__ 357 | │ │ │ │ └── ... 358 | │ │ │ ├── _ -> feature of private things 359 | │ │ │ │ └── ... 360 | │ │ │ └── ... 361 | │ │ ├── shared -> shared feature module 362 | │ │ │ └── ... 363 | │ │ └── index.js 364 | │ ├── app 365 | │ │ ├── __tests__ 366 | │ │ │ └── ... 367 | │ │ ├── _ -> app of private things 368 | │ │ │ └── ... 369 | │ │ ├── core -> core feature module 370 | │ │ │ └── ... 371 | │ │ ├── -> feature modules 372 | │ │ │ ├── __tests__ 373 | │ │ │ │ ├── actions.spec.js 374 | │ │ │ │ ├── .e2e-spec.js 375 | │ │ │ │ ├── .spec.js 376 | │ │ │ │ ├── .spec.js 377 | │ │ │ │ ├── reducer.spec.js 378 | │ │ │ │ └── selectors.spec.js 379 | │ │ │ ├── _ -> feature of private things 380 | │ │ │ │ └── ... 381 | │ │ │ ├── actions.js 382 | │ │ │ ├── constants.js 383 | │ │ │ ├── .vue 384 | │ │ │ ├── .js 385 | │ │ │ ├── reducer.js 386 | │ │ │ ├── selectors.js 387 | │ │ │ └── types.js 388 | │ │ ├── -> module group 389 | │ │ │ └── -> feature modules 390 | │ │ │ ├── __tests__ 391 | │ │ │ │ ├── actions.spec.js 392 | │ │ │ │ ├── .e2e-spec.js 393 | │ │ │ │ ├── .spec.js 394 | │ │ │ │ ├── .spec.js 395 | │ │ │ │ ├── reducer.spec.js 396 | │ │ │ │ └── selectors.spec.js 397 | │ │ │ ├── _ -> feature of private things 398 | │ │ │ │ └── ... 399 | │ │ │ ├── actions.js 400 | │ │ │ ├── constants.js 401 | │ │ │ ├── .js 402 | │ │ │ ├── .js 403 | │ │ │ ├── reducer.js 404 | │ │ │ ├── selectors.js 405 | │ │ │ └── types.js 406 | │ │ ├── shared -> shared feature module 407 | │ │ │ └── ... 408 | │ │ ├── actions.js 409 | │ │ ├── App.js 410 | │ │ ├── constants.js 411 | │ │ ├── epics.js 412 | │ │ ├── reducer.js 413 | │ │ ├── sagas.js 414 | │ │ ├── selectors.js 415 | │ │ └── types.js 416 | │ ├── assets -> datas, fonts, images, medias 417 | │ │ └── ... 418 | │ ├── client.js 419 | │ ├── index.html 420 | │ └── server.js 421 | ├── tools 422 | │ └── ... 423 | ├── .babelrc 424 | ├── .editorconfig 425 | ├── .eslintrc 426 | ├── .firebaserc 427 | ├── .flowconfig 428 | ├── .gitignore 429 | ├── Dockerfile 430 | ├── LICENSE 431 | ├── README.md 432 | ├── circle.yml 433 | ├── docker-compose.yml 434 | ├── env.js 435 | ├── firebase.json 436 | ├── jest.config.js 437 | ├── package.json 438 | ├── webpack.config.js 439 | └── yarn.lock 440 | ``` 441 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | const env = require('./env'); 2 | 3 | module.exports = api => { 4 | api.cache(true); 5 | 6 | return { 7 | env: { 8 | app: { 9 | presets: [ 10 | [ 11 | '@babel/preset-env', 12 | { 13 | useBuiltIns: 'entry', 14 | corejs: '3', 15 | shippedProposals: true, 16 | targets: '> 0.25%, not dead', 17 | }, 18 | ], 19 | '@babel/preset-react', 20 | '@babel/preset-flow', 21 | ], 22 | plugins: [ 23 | '@babel/plugin-transform-runtime', 24 | '@babel/plugin-syntax-dynamic-import', 25 | '@babel/plugin-proposal-class-properties', 26 | ['emotion', { sourceMap: env.NODE_ENV !== 'production' }], 27 | 'lodash', 28 | ], 29 | }, 30 | test: { 31 | presets: [ 32 | [ 33 | '@babel/preset-env', 34 | { 35 | useBuiltIns: 'entry', 36 | corejs: '3', 37 | shippedProposals: true, 38 | targets: { 39 | node: 'current', 40 | }, 41 | }, 42 | ], 43 | '@babel/preset-react', 44 | '@babel/preset-flow', 45 | ], 46 | plugins: [ 47 | '@babel/plugin-transform-runtime', 48 | '@babel/plugin-syntax-dynamic-import', 49 | '@babel/plugin-proposal-class-properties', 50 | 'emotion', 51 | 'lodash', 52 | 'dynamic-import-node', 53 | ], 54 | }, 55 | }, 56 | }; 57 | }; 58 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | executors: 4 | react-play: 5 | docker: 6 | - image: circleci/node:12 7 | working_directory: ~/React-Play 8 | 9 | jobs: 10 | 11 | build: 12 | executor: react-play 13 | steps: 14 | - checkout 15 | - setup_remote_docker 16 | - run: 17 | name: Combat preparation 18 | command: docker-compose up -d default 19 | - run: 20 | name: Build the application 21 | command: docker-compose exec default yarn build 22 | 23 | test: 24 | executor: react-play 25 | steps: 26 | - checkout 27 | - setup_remote_docker 28 | - run: 29 | name: Combat preparation 30 | command: | 31 | docker-compose up -d default 32 | # docker-compose exec default yarn _firebase use development --token $FIREBASE_TOKEN 33 | docker-compose exec default yarn build 34 | # docker-compose exec -d default yarn _firebase serve --only hosting --port 8000 --token $FIREBASE_TOKEN 35 | - run: 36 | name: Check the code 37 | command: | 38 | docker-compose exec default yarn lint 39 | - run: 40 | name: Test the application 41 | command: | 42 | docker-compose exec default yarn unit 43 | docker-compose exec default yarn _codecov -t $CODECOV_TOKEN 44 | # - run: 45 | # name: End-to-end UI tests 46 | # command: | 47 | # docker-compose exec default sh -c "sleep 10" 48 | # docker-compose exec default yarn e2e 49 | 50 | deploy: 51 | executor: react-play 52 | steps: 53 | - checkout 54 | - setup_remote_docker 55 | - run: 56 | name: Log in to Docker 57 | command: echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin 58 | - run: 59 | name: Deploy the project 60 | command: | 61 | # if [[ "${CIRCLE_BRANCH}" == "develop" ]]; then 62 | # docker-compose up -d dev 63 | # docker-compose exec dev yarn _firebase use development --token $FIREBASE_TOKEN 64 | # docker-compose exec dev yarn _firebase deploy --only hosting --token $FIREBASE_TOKEN 65 | # fi 66 | 67 | # if [[ "${CIRCLE_BRANCH}" == *"release"* ]]; then 68 | # docker-compose up -d stage 69 | # docker-compose exec stage yarn _firebase use staging --token $FIREBASE_TOKEN 70 | # docker-compose exec stage yarn _firebase deploy --only hosting --token $FIREBASE_TOKEN 71 | # fi 72 | 73 | # if [[ "${CIRCLE_BRANCH}" == "master" ]]; then 74 | # docker-compose up -d prod 75 | # docker-compose exec prod yarn _firebase use production --token $FIREBASE_TOKEN 76 | # docker-compose exec prod yarn _firebase deploy --only hosting --token $FIREBASE_TOKEN 77 | # fi 78 | 79 | workflows: 80 | version: 2 81 | build-test-deploy: 82 | jobs: 83 | - build 84 | - test: 85 | requires: 86 | - build 87 | - deploy: 88 | requires: 89 | - test 90 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | 5 | default: 6 | image: default 7 | build: 8 | context: . 9 | dockerfile: Dockerfile 10 | ports: 11 | - "8000:8000" 12 | volumes: 13 | - yarn:/home/node/.cache/yarn 14 | tty: true 15 | 16 | dev: 17 | image: dev 18 | build: 19 | context: . 20 | dockerfile: ./tools/dev.Dockerfile 21 | volumes: 22 | - yarn:/home/node/.cache/yarn 23 | tty: true 24 | 25 | stage: 26 | image: stage 27 | build: 28 | context: . 29 | dockerfile: ./tools/stage.Dockerfile 30 | volumes: 31 | - yarn:/home/node/.cache/yarn 32 | tty: true 33 | 34 | prod: 35 | image: prod 36 | build: 37 | context: . 38 | dockerfile: ./tools/prod.Dockerfile 39 | volumes: 40 | - yarn:/home/node/.cache/yarn 41 | tty: true 42 | 43 | volumes: 44 | yarn: 45 | -------------------------------------------------------------------------------- /env.js: -------------------------------------------------------------------------------- 1 | function Environments() { 2 | this.NODE_ENV = process.env.NODE_ENV || 'development'; 3 | 4 | this.PROJECT_NAME = process.env.PROJECT_NAME || 'react-by-example'; 5 | 6 | this.HOST_NAME = process.env.HOST_NAME || '0.0.0.0'; 7 | 8 | this.SITE_PORT = process.env.SITE_PORT || 8000; 9 | this.SITE_URL = process.env.SITE_URL || `http://${this.HOST_NAME}:${this.SITE_PORT}`; 10 | 11 | this.APP_BASE = process.env.APP_BASE || '/'; 12 | 13 | this.GOOGLE_ANALYTICS = process.env.GOOGLE_ANALYTICS || ''; 14 | 15 | this.SENTRY_DSN = process.env.SENTRY_DSN || null; 16 | } 17 | 18 | module.exports = new Environments(); 19 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const env = require('./env'); 2 | 3 | switch (process.env.JEST_ENV) { 4 | case 'app': 5 | module.exports = { 6 | coveragePathIgnorePatterns: ['/node_modules/', '/tools/'], 7 | moduleFileExtensions: ['js', 'jsx'], 8 | moduleNameMapper: { 9 | '~(.*)': '/src$1', 10 | }, 11 | setupFilesAfterEnv: ['/tools/setup-app.js'], 12 | snapshotSerializers: ['jest-emotion'], 13 | testPathIgnorePatterns: [ 14 | '/node_modules/', 15 | '/.flow-typed', 16 | '.*\\.e2e-spec.js$', 17 | ], 18 | testURL: `http://${env.HOST_NAME}/`, 19 | transform: { 20 | '^.+\\.js$': 'babel-jest', 21 | '^[./a-zA-Z0-9$_-]+\\.(bmp|gif|jpg|jpeg|png|psd|svg|webp)$': 22 | '/tools/assets-transform.js', 23 | }, 24 | }; 25 | break; 26 | 27 | case 'e2e': 28 | module.exports = { 29 | setupFilesAfterEnv: ['/tools/setup-e2e.js'], 30 | testPathIgnorePatterns: [ 31 | '/node_modules/', 32 | '/.flow-typed', 33 | '.*\\.spec.js$', 34 | ], 35 | testURL: `http://${env.HOST_NAME}/`, 36 | transform: { 37 | '^.+\\.js$': 'babel-jest', 38 | }, 39 | }; 40 | break; 41 | 42 | default: 43 | module.exports = {}; 44 | } 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-starter", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "cross-env BABEL_ENV=app webpack-dev-server --progress", 7 | "build": "rimraf public && cross-env BABEL_ENV=app webpack --env.prod", 8 | "lint": "eslint 'src/**/*.js'", 9 | "unit": "cross-env BABEL_ENV=app JEST_ENV=app jest \"src/.*\\.js$\" --coverage --forceExit", 10 | "e2e": "cross-env BABEL_ENV=test JEST_ENV=e2e jest \"src/.*\\.js$\" --forceExit", 11 | "typed": "flow-typed install", 12 | "_codecov": "codecov", 13 | "_firebase": "firebase" 14 | }, 15 | "dependencies": { 16 | "@apollo/react-hooks": "3.0.1", 17 | "@babel/runtime": "7.5.5", 18 | "@emotion/core": "10.0.17", 19 | "@emotion/styled": "10.0.17", 20 | "@loadable/component": "5.10.2", 21 | "@material-ui/core": "4.3.3", 22 | "@material-ui/icons": "4.2.1", 23 | "axios": "0.19.0", 24 | "classnames": "2.2.6", 25 | "connected-react-router": "6.5.2", 26 | "core-js": "3.2.1", 27 | "graphql": "14.4.2", 28 | "graphql-request": "1.8.2", 29 | "graphql-tag": "2.10.1", 30 | "history": "4.9.0", 31 | "intl": "1.2.5", 32 | "lodash": "4.17.15", 33 | "prop-types": "15.7.2", 34 | "raven": "2.6.4", 35 | "react": "16.9.0", 36 | "react-apollo": "3.0.1", 37 | "react-dom": "16.9.0", 38 | "react-helmet": "5.2.1", 39 | "react-hook-form": "3.27.0", 40 | "react-intl": "3.1.9", 41 | "react-markdown": "4.2.2", 42 | "react-redux": "7.1.0", 43 | "react-router": "5.0.1", 44 | "react-router-dom": "5.0.1", 45 | "react-spring": "8.0.27", 46 | "react-use": "11.0.0", 47 | "recompose": "0.30.0", 48 | "redux": "4.0.4", 49 | "redux-actions": "2.6.5", 50 | "redux-dynamic-manager": "0.1.0", 51 | "redux-thunk": "2.3.0", 52 | "register-service-worker": "1.6.2", 53 | "reselect": "4.0.0", 54 | "reselect-computed": "0.1.2", 55 | "use-react-router": "1.0.7" 56 | }, 57 | "devDependencies": { 58 | "@babel/core": "7.5.5", 59 | "@babel/plugin-proposal-class-properties": "7.5.5", 60 | "@babel/plugin-syntax-dynamic-import": "7.2.0", 61 | "@babel/plugin-transform-runtime": "7.5.5", 62 | "@babel/preset-env": "7.5.5", 63 | "@babel/preset-flow": "7.0.0", 64 | "@babel/preset-react": "7.0.0", 65 | "@testing-library/react": "8.0.6", 66 | "babel-eslint": "10.0.2", 67 | "babel-jest": "24.8.0", 68 | "babel-loader": "8.0.6", 69 | "babel-plugin-emotion": "10.0.19", 70 | "babel-plugin-lodash": "3.3.4", 71 | "codecov": "3.6.5", 72 | "copy-webpack-plugin": "4.6.0", 73 | "cross-env": "5.2.0", 74 | "eslint": "5.9.0", 75 | "eslint-config-airbnb": "17.1.0", 76 | "eslint-config-prettier": "6.0.0", 77 | "eslint-import-resolver-webpack": "0.10.1", 78 | "eslint-plugin-import": "2.14.0", 79 | "eslint-plugin-jsx-a11y": "6.1.2", 80 | "eslint-plugin-prettier": "3.1.0", 81 | "eslint-plugin-react": "7.11.1", 82 | "eslint-plugin-react-hooks": "1.6.0", 83 | "firebase-tools": "6.1.1", 84 | "flow-bin": "0.108.0", 85 | "flow-typed": "2.6.1", 86 | "html-webpack-plugin": "4.0.0-alpha", 87 | "jest": "24.8.0", 88 | "jest-emotion": "10.0.17", 89 | "prettier": "1.18.2", 90 | "process-envify": "0.1.3", 91 | "puppeteer": "1.19.0", 92 | "react-test-renderer": "16.9.0", 93 | "redux-mock-store": "1.5.3", 94 | "regenerator-runtime": "0.13.1", 95 | "robotstxt-webpack-plugin": "4.0.1", 96 | "script-ext-html-webpack-plugin": "2.1.3", 97 | "sitemap-webpack-plugin": "0.6.0", 98 | "stylelint": "11.0.0", 99 | "stylelint-config-standard": "19.0.0", 100 | "url-loader": "1.1.2", 101 | "webpack": "4.28.1", 102 | "webpack-cli": "3.2.1", 103 | "webpack-dev-server": "3.1.14", 104 | "workbox-webpack-plugin": "3.6.3" 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | 'postcss-import': { 4 | path: ['src/assets/styles'], 5 | }, 6 | 'postcss-preset-env': { 7 | stage: 0, 8 | browserslist: 'last 2 versions', 9 | }, 10 | 'postcss-nested': {}, 11 | cssnano: {}, 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | 4 | import { makeStyles } from '@material-ui/core/styles'; 5 | import Drawer from '@material-ui/core/Drawer'; 6 | import AppBar from '@material-ui/core/AppBar'; 7 | import Toolbar from '@material-ui/core/Toolbar'; 8 | import IconButton from '@material-ui/core/IconButton'; 9 | import List from '@material-ui/core/List'; 10 | import ListItem from '@material-ui/core/ListItem'; 11 | import ListItemIcon from '@material-ui/core/ListItemIcon'; 12 | import ListItemText from '@material-ui/core/ListItemText'; 13 | 14 | import MenuIcon from '@material-ui/icons/Menu'; 15 | import FaceIcon from '@material-ui/icons/Face'; 16 | import Typography from '@material-ui/core/Typography'; 17 | 18 | import Routes from '~/core/Router'; 19 | 20 | const useStyles = makeStyles(theme => ({ 21 | root: { 22 | 'flex-grow': 1, 23 | 'z-index': 1, 24 | overflow: 'hidden', 25 | position: 'relative', 26 | display: 'flex', 27 | }, 28 | 'o-title': { 29 | 'text-decoration': 'none', 30 | }, 31 | menu: { 32 | color: '#fff', 33 | }, 34 | appBar: { 35 | 'z-index': theme.zIndex.drawer + 1, 36 | }, 37 | drawerPaper: { 38 | position: 'fixed', 39 | width: '300px', 40 | }, 41 | content: { 42 | 'flex-grow': 1, 43 | 'background-color': theme.palette.background.default, 44 | padding: theme.spacing(3), 45 | 'margin-left': '300px', 46 | }, 47 | toolbar: theme.mixins.toolbar, 48 | })); 49 | 50 | const App = () => { 51 | const classes = useStyles(); 52 | 53 | return ( 54 |
55 | 56 | 57 | 58 | 59 | 60 | 68 | Oh My React 69 | 70 | 71 | 72 | 73 | 74 |
75 | {/* TODO: react-router-config */} 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 |
132 |
133 |
134 | 135 |
136 |
137 |
138 | ); 139 | }; 140 | 141 | export default App; 142 | -------------------------------------------------------------------------------- /src/actions.js: -------------------------------------------------------------------------------- 1 | import { SET_DATA } from './constants'; 2 | 3 | export const setLanguage = () => ({}); 4 | export const initialLanguage = () => ({}); 5 | 6 | export const setData = data => ({ type: SET_DATA, data }); 7 | -------------------------------------------------------------------------------- /src/assets/datas/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Oh My Vue", 3 | "short_name": "Oh My Vue", 4 | "start_url": "/", 5 | "theme_color": "#2F3BA2", 6 | "background_color": "#3E4EB8", 7 | "orientation": "portrait", 8 | "display": "standalone", 9 | "icons": [ 10 | { 11 | "src": "../images/icon-32x32.png", 12 | "sizes": "32x32", 13 | "type": "image/png" 14 | }, 15 | { 16 | "src": "../images/icon-64x64.png", 17 | "sizes": "64x64", 18 | "type": "image/png" 19 | }, 20 | { 21 | "src": "../images/icon-128x128.png", 22 | "sizes": "128x128", 23 | "type": "image/png" 24 | }, 25 | { 26 | "src": "../images/icon-144x144.png", 27 | "sizes": "144x144", 28 | "type": "image/png" 29 | }, 30 | { 31 | "src": "../images/icon-152x152.png", 32 | "sizes": "152x152", 33 | "type": "image/png" 34 | }, 35 | { 36 | "src": "../images/icon-192x192.png", 37 | "sizes": "192x192", 38 | "type": "image/png" 39 | }, 40 | { 41 | "src": "../images/icon-200x200.png", 42 | "sizes": "200x200", 43 | "type": "image/png" 44 | }, 45 | { 46 | "src": "../images/icon-256x256.png", 47 | "sizes": "256x256", 48 | "type": "image/png" 49 | }, 50 | { 51 | "src": "../images/icon-512x512.png", 52 | "sizes": "512x512", 53 | "type": "image/png" 54 | } 55 | ] 56 | } 57 | -------------------------------------------------------------------------------- /src/assets/fonts/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Shyam-Chen/React-Starter/276a736b1c1a52d4e5bebd21e99fa8d7046d18c9/src/assets/fonts/.gitkeep -------------------------------------------------------------------------------- /src/assets/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Shyam-Chen/React-Starter/276a736b1c1a52d4e5bebd21e99fa8d7046d18c9/src/assets/images/favicon.ico -------------------------------------------------------------------------------- /src/assets/images/icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Shyam-Chen/React-Starter/276a736b1c1a52d4e5bebd21e99fa8d7046d18c9/src/assets/images/icon-144x144.png -------------------------------------------------------------------------------- /src/assets/images/react.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Shyam-Chen/React-Starter/276a736b1c1a52d4e5bebd21e99fa8d7046d18c9/src/assets/images/react.png -------------------------------------------------------------------------------- /src/assets/medias/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Shyam-Chen/React-Starter/276a736b1c1a52d4e5bebd21e99fa8d7046d18c9/src/assets/medias/.gitkeep -------------------------------------------------------------------------------- /src/assets/styles/_medias.css: -------------------------------------------------------------------------------- 1 | /** 2 | * @example 3 | * @import '_medias'; 4 | * 5 | * @media (--viewport-mobile) { 6 | * { 7 | * : var(--foo-bar); 8 | * } 9 | * } 10 | */ 11 | 12 | @custom-media --viewport-mobile (width <= 599px); 13 | -------------------------------------------------------------------------------- /src/assets/styles/_root.css: -------------------------------------------------------------------------------- 1 | /** 2 | * @example 3 | * @import '_root'; 4 | * 5 | * { 6 | * : var(--foo-bar); 7 | * } 8 | */ 9 | 10 | :root { 11 | --foo-bar: 'foo bar'; 12 | } 13 | -------------------------------------------------------------------------------- /src/assets/styles/_selectors.css: -------------------------------------------------------------------------------- 1 | /** 2 | * @example 3 | * @import '_selectors'; 4 | * 5 | * :--heading { 6 | * : ; 7 | * } 8 | */ 9 | 10 | @custom-selector :--heading h1, h2, h3, h4, h5, h6; 11 | -------------------------------------------------------------------------------- /src/assets/styles/global.css: -------------------------------------------------------------------------------- 1 | @import '_root'; 2 | @import '_medias'; 3 | @import '_selectors'; 4 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | export const INITIAL = {}; 2 | 3 | export const SET_DATA = 'SET_DATA'; 4 | -------------------------------------------------------------------------------- /src/core/Router.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Route, Switch } from 'react-router'; 3 | import loadable from '@loadable/component' 4 | import { createBrowserHistory } from 'history'; 5 | 6 | import Home from '~/home/Home'; 7 | import NotFound from '~/not-found/NotFound'; 8 | 9 | export const history = createBrowserHistory({ pathname: '/' }); 10 | 11 | const Router = () => ( 12 | <> 13 | 14 | 15 | 16 | import('~/shell/hello-world/HelloWorld'))} 19 | /> 20 | import('~/shell/SortFilterList'))} 23 | /> 24 | import('~/shell/RecursiveList'))} 27 | /> 28 | 31 | import('~/shell/crud-operations/basic/Basic'), 32 | )} 33 | /> 34 | import('~/shell/github-repos/GithubRepos'))} 37 | /> 38 | import('~/shell/markdown-editor/MarkdownEditor'))} 42 | /> 43 | import('~/shell/markdown-editor/ArticleDetail'))} 47 | /> 48 | 49 | 50 | 51 | 52 | ); 53 | 54 | export default Router; 55 | -------------------------------------------------------------------------------- /src/core/i18n/en.js: -------------------------------------------------------------------------------- 1 | export default {}; 2 | -------------------------------------------------------------------------------- /src/core/i18n/index.js: -------------------------------------------------------------------------------- 1 | import en from './en'; 2 | 3 | export default { 4 | en, 5 | }; 6 | -------------------------------------------------------------------------------- /src/core/i18n/ja.js: -------------------------------------------------------------------------------- 1 | export default {}; 2 | -------------------------------------------------------------------------------- /src/core/i18n/ko.js: -------------------------------------------------------------------------------- 1 | export default {}; 2 | -------------------------------------------------------------------------------- /src/core/i18n/zh.js: -------------------------------------------------------------------------------- 1 | export default {}; 2 | -------------------------------------------------------------------------------- /src/core/material.js: -------------------------------------------------------------------------------- 1 | import { createMuiTheme } from '@material-ui/core/styles'; 2 | import blue from '@material-ui/core/colors/blue'; 3 | import pink from '@material-ui/core/colors/pink'; 4 | 5 | export const theme = createMuiTheme({ 6 | palette: { 7 | primary: { main: blue[500] }, 8 | secondary: { main: pink[500] }, 9 | type: 'light', 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /src/core/request.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { GraphQLClient } from 'graphql-request'; 3 | 4 | const authorization = `Bearer ${localStorage.getItem('token')}`; 5 | 6 | const restClient = axios.create({ 7 | baseURL: process.env.FUNC_URL, 8 | }); 9 | 10 | restClient.defaults.headers.common.Authorization = authorization; 11 | 12 | const graphqlClient = new GraphQLClient(`${process.env.FUNC_URL}/graphql`, { 13 | headers: { 14 | Authorization: authorization, 15 | }, 16 | }); 17 | 18 | export default { 19 | restClient, 20 | graphqlClient, 21 | }; 22 | -------------------------------------------------------------------------------- /src/core/service-worker.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import { register } from 'register-service-worker'; 3 | 4 | export default () => { 5 | register('/service-worker.js', { 6 | registrationOptions: { scope: './' }, 7 | ready() { 8 | console.log('Service worker is active.'); 9 | }, 10 | registered() { 11 | console.log('Service worker has been registered.'); 12 | }, 13 | cached() { 14 | console.log('Content has been cached for offline use.'); 15 | }, 16 | updatefound() { 17 | console.log('New content is downloading.'); 18 | }, 19 | updated() { 20 | console.log('New content is available; please refresh.'); 21 | window.location.reload(); 22 | }, 23 | offline() { 24 | console.log( 25 | 'No internet connection found. App is running in offline mode.', 26 | ); 27 | }, 28 | error(error) { 29 | console.error('Error during service worker registration:', error); 30 | }, 31 | }); 32 | }; 33 | -------------------------------------------------------------------------------- /src/core/store.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware, compose } from 'redux'; 2 | import { createReducerManager, bindReducerManager } from 'redux-dynamic-manager'; 3 | import { routerMiddleware, connectRouter } from 'connected-react-router'; 4 | import thunkMiddleware from 'redux-thunk'; 5 | 6 | import appReducer from '~/reducer'; 7 | 8 | import { history } from './Router'; 9 | 10 | const rootReducer = { 11 | app: appReducer, 12 | router: connectRouter(history), 13 | }; 14 | 15 | const reducerManager = createReducerManager(rootReducer); 16 | 17 | bindReducerManager(reducerManager); 18 | 19 | export const configureStore = () => { 20 | const composeEnhancer = 21 | window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; 22 | 23 | const store = createStore( 24 | reducerManager.reduce, 25 | composeEnhancer( 26 | applyMiddleware(routerMiddleware(history), thunkMiddleware), 27 | ), 28 | ); 29 | 30 | return store; 31 | }; 32 | -------------------------------------------------------------------------------- /src/home/Home.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | // import { makeStyles } from '@material-ui/core/styles'; 3 | 4 | // const useStyles = makeStyles(() => ({})); 5 | 6 | const Home = ()=> { 7 | // const classes = useStyles(); 8 | 9 | return ( 10 |
11 |

Oh My React

12 |
13 | ); 14 | }; 15 | 16 | export default Home; 17 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Oh My React 9 | 10 | 11 | 12 | 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 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'react-dom'; 3 | import { Router } from 'react-router'; 4 | import { Provider } from 'react-redux'; 5 | import { IntlProvider } from 'react-intl'; 6 | import { ConnectedRouter } from 'connected-react-router'; 7 | import CssBaseline from '@material-ui/core/CssBaseline'; 8 | import { MuiThemeProvider } from '@material-ui/core/styles'; 9 | 10 | import { history } from '~/core/Router'; 11 | import { configureStore } from '~/core/store'; 12 | import i18n from '~/core/i18n'; 13 | import { theme } from '~/core/material'; 14 | 15 | import nestPairs from '~/shared/nest-pairs'; 16 | 17 | import App from '~/App'; 18 | 19 | const store = configureStore(); 20 | const language = navigator.language.split(/[-_]/)[0]; 21 | 22 | const Providers = nestPairs( 23 | [Router, { history }], 24 | [Provider, { store }], 25 | [ConnectedRouter, { history }], 26 | [IntlProvider, { locale: language, messages: i18n[language] }], 27 | [MuiThemeProvider, { theme }], 28 | ); 29 | 30 | render( 31 | 32 | 33 | {/* TODO: prefix lang */} 34 | {/* */} 35 | 36 | , 37 | document.querySelector('#app'), 38 | ); 39 | -------------------------------------------------------------------------------- /src/not-found/NotFound.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { compose } from 'recompose'; 3 | 4 | export const NotFound = () => ( 5 |
6 |

7 | 404 8 |

9 |
10 | ); 11 | 12 | export default compose()(NotFound); 13 | -------------------------------------------------------------------------------- /src/reducer.js: -------------------------------------------------------------------------------- 1 | import { handleActions } from 'redux-actions'; 2 | 3 | import assignDeep from '~/shared/assign-deep'; 4 | 5 | import { INITIAL, SET_DATA } from './constants'; 6 | 7 | export default handleActions({ 8 | [SET_DATA](state, { data }) { 9 | return { ...assignDeep(state, data) }; 10 | }, 11 | }, INITIAL); 12 | -------------------------------------------------------------------------------- /src/selectors.js: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect'; 2 | 3 | export const selectNAME = createSelector( 4 | app => app, 5 | app => app, 6 | ); 7 | -------------------------------------------------------------------------------- /src/shared/assign-deep.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-restricted-syntax */ 2 | const isObject = item => { 3 | return item && typeof item === 'object' && !Array.isArray(item); 4 | }; 5 | 6 | const assignDeep = (target, ...sources) => { 7 | if (!sources.length) return target; 8 | const source = sources.shift(); 9 | 10 | if (isObject(target) && isObject(source)) { 11 | for (const key in source) { 12 | if (isObject(source[key])) { 13 | if (!target[key]) Object.assign(target, { [key]: {} }); 14 | assignDeep(target[key], source[key]); 15 | } else { 16 | Object.assign(target, { [key]: source[key] }); 17 | } 18 | } 19 | } 20 | 21 | return assignDeep(target, ...sources); 22 | }; 23 | 24 | export default assignDeep; 25 | -------------------------------------------------------------------------------- /src/shared/compose.js: -------------------------------------------------------------------------------- 1 | export default (...funcs) => 2 | funcs.reduce((f, g) => (...args) => g(f(...args)), arg => arg); 3 | -------------------------------------------------------------------------------- /src/shared/nest-pairs.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { nest } from 'recompose'; 3 | 4 | export default (...componentPropPairs) => { 5 | return nest.apply( 6 | this, 7 | componentPropPairs.map(([ComponentClass, props]) => ({ children }) => 8 | // eslint-disable-next-line react/no-children-prop 9 | React.createElement(ComponentClass, { ...props, children }), 10 | ), 11 | ); 12 | }; 13 | -------------------------------------------------------------------------------- /src/shell/RecursiveList.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | const RecursiveItem = (props) => { 4 | return ( 5 | <> 6 | {props.data.map(item => ( 7 |
  • 8 | {item.label} 9 | 10 | {item.children && item.children.length && ( 11 |
      12 | 13 |
    14 | )} 15 |
  • 16 | ))} 17 | 18 | ); 19 | }; 20 | 21 | 22 | export const RecursiveList = () => { 23 | const [list] = useState([ 24 | { 25 | label: '1', 26 | children: [ 27 | { 28 | label: '1-1', 29 | children: [ 30 | { label: '1-1-1' }, 31 | { label: '1-1-2' }, 32 | { 33 | label: '1-1-3', 34 | children: [{ label: '1-1-3-1' }], 35 | }, 36 | ], 37 | }, 38 | { label: '1-2' }, 39 | ], 40 | }, 41 | { 42 | label: '2', 43 | children: [ 44 | { label: '2-1', children: [{ label: '2-1-1' }] }, 45 | { label: '2-2' }, 46 | ], 47 | }, 48 | ]); 49 | 50 | return ( 51 | <> 52 |
      53 | 54 |
    55 | 56 | ); 57 | }; 58 | 59 | export default RecursiveList; 60 | -------------------------------------------------------------------------------- /src/shell/SortFilterList.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { compose } from 'recompose'; 3 | import axios from 'axios'; 4 | 5 | import { makeStyles } from '@material-ui/core/styles'; 6 | import Button from '@material-ui/core/Button'; 7 | import Typography from '@material-ui/core/Typography'; 8 | 9 | import HeadsetIcon from '@material-ui/icons/Headset'; 10 | import EventIcon from '@material-ui/icons/Event'; 11 | import VideoLibraryIcon from '@material-ui/icons/VideoLibrary'; 12 | 13 | const useStyles = makeStyles(() => ({ 14 | 'o-button-groups': { 15 | display: 'flex', 16 | 'flex-flow': 'row wrap', 17 | 'max-width': '1280px', 18 | 'justify-content': 'flex-start', 19 | }, 20 | 'o-button-group': { 21 | display: 'flex', 22 | 'align-items': 'center', 23 | }, 24 | 'o-button': { 25 | margin: '0.5rem', 26 | }, 27 | 'o-cards': { 28 | display: 'flex', 29 | 'flex-flow': 'row wrap', 30 | 'max-width': '1280px', 31 | margin: 'auto -0.75rem', 32 | }, 33 | 'o-card': { 34 | width: '240px', 35 | 'box-sizing': 'border-box', 36 | 'box-shadow': '0 3px 6px rgba(0, 0, 0, 0.16), 0 3px 6px rgba(0, 0, 0, 0.23)', 37 | margin: '0.75rem', 38 | }, 39 | 'o-card-media': { 40 | position: 'relative', 41 | }, 42 | 'o-card-image': { 43 | width: '240px', 44 | height: '135px', 45 | }, 46 | 'o-card-time': { 47 | position: 'absolute', 48 | right: '0.5rem', 49 | bottom: '0.5rem', 50 | padding: '0.25rem', 51 | color: '#fff', 52 | background: '#000', 53 | }, 54 | 'o-card-content': { 55 | padding: '0.25rem 0.5rem', 56 | }, 57 | 'o-card-text': { 58 | display: 'flex', 59 | 'align-items': 'center', 60 | padding: '0.25rem 0', 61 | }, 62 | 'o-card-icon': { 63 | 'margin-right': '0.25rem', 64 | }, 65 | })); 66 | 67 | const truncate = (value, length = 15) => { 68 | if (!value) return ''; 69 | if (value.length <= length) return value; 70 | return `${value.substring(0, length)}...`; 71 | }; 72 | 73 | const convertSeconds = value => { 74 | const timeSubstr = (start = 11, end = 8) => 75 | new Date(value * 1000).toISOString().substr(start, end); 76 | 77 | if (value < 3600) return timeSubstr(14, 5); 78 | return timeSubstr(); 79 | }; 80 | 81 | const timeSince = date => { 82 | const ago = new Date(date); 83 | const now = new Date(); 84 | 85 | const seconds = Math.round(Math.abs((now.getTime() - ago.getTime()) / 1000)); 86 | const minutes = Math.round(Math.abs(seconds / 60)); 87 | const hours = Math.round(Math.abs(minutes / 60)); 88 | const days = Math.round(Math.abs(hours / 24)); 89 | const months = Math.round(Math.abs(days / 30.416)); 90 | const years = Math.round(Math.abs(days / 365)); 91 | 92 | if (seconds <= 45) return 'a few seconds ago'; 93 | if (seconds <= 90) return 'a minute ago'; 94 | if (minutes <= 45) return `${minutes} minutes ago`; 95 | if (minutes <= 90) return 'an hour ago'; 96 | if (hours <= 22) return `${hours} hours ago`; 97 | if (hours <= 36) return 'a day ago'; 98 | if (days <= 25) return `${days} days ago`; 99 | if (days <= 45) return 'a month ago'; 100 | if (days <= 345) return `${months} months ago`; 101 | if (days <= 545) return 'a year ago'; 102 | return `${years} years ago`; 103 | }; 104 | 105 | const filerList = length => list => 106 | list.filter(item => { 107 | if (length === 'lessThanFive') return item.duration < 300; 108 | if (length === 'fiveToTen') 109 | return item.duration >= 300 && item.duration <= 600; 110 | if (length === 'moreThanTen') return item.duration > 600; 111 | 112 | return list; 113 | }); 114 | 115 | const sortList = sort => list => { 116 | const typedList = type => list.sort((a, b) => a[type] - b[type]).reverse(); 117 | 118 | if (sort === 'published') return typedList('publish'); 119 | if (sort === 'views') return typedList('views'); 120 | if (sort === 'collections') return typedList('collectCount'); 121 | 122 | return list; 123 | }; 124 | 125 | const Home = () => { 126 | const classes = useStyles(); 127 | 128 | const [sort, setSort] = useState('published'); 129 | const [length, setLength] = useState('any'); 130 | const [list, setList] = useState([]); 131 | const [isLoading, setIsLoading] = useState(true); 132 | 133 | const composeList = compose(sortList(sort), filerList(length))(list); 134 | 135 | useEffect(() => { 136 | axios.get('https://us-central1-lithe-window-713.cloudfunctions.net/fronted-demo') 137 | .then(({ data }) => { 138 | setList(data.data); 139 | setIsLoading(false); 140 | }); 141 | }, []); 142 | 143 | return ( 144 |
    145 |
    146 |
    147 | Sort 148 | 149 | 150 | 151 |
    152 | 153 |
    154 | Length 155 | 156 | 157 | 158 | 159 |
    160 |
    161 | 162 | 163 | { 164 | !isLoading 165 | ? ( 166 |
    167 | { 168 | composeList.length !== 0 169 | ? ( 170 |
    171 | { 172 | composeList.map(item => ( 173 |
    174 |
    175 | Thumbnail 176 | {convertSeconds(item.duration)} 177 |
    178 | 179 |
    180 | {truncate(item.title, 50)} 181 | {timeSince(item.publish * 1000)} 182 | {item.views.toLocaleString('en-US')} 183 | {item.collectCount.toLocaleString('en-US')} 184 |
    185 |
    186 | )) 187 | } 188 |
    189 | ) 190 | : No results 191 | } 192 |
    193 | ) 194 | :
    Loading...
    195 | } 196 |
    197 | ); 198 | }; 199 | 200 | export default Home; 201 | -------------------------------------------------------------------------------- /src/shell/crud-operations/basic/Basic.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useSelector, useDispatch } from 'react-redux'; 3 | import { dynamic } from 'redux-dynamic-manager'; 4 | import { makeStyles } from '@material-ui/core/styles'; 5 | import Typography from '@material-ui/core/Typography'; 6 | import TextField from '@material-ui/core/TextField'; 7 | import Button from '@material-ui/core/Button'; 8 | import Paper from '@material-ui/core/Paper'; 9 | import Toolbar from '@material-ui/core/Toolbar'; 10 | import Table from '@material-ui/core/Table'; 11 | import TableHead from '@material-ui/core/TableHead'; 12 | import TableBody from '@material-ui/core/TableBody'; 13 | import TableRow from '@material-ui/core/TableRow'; 14 | import TableCell from '@material-ui/core/TableCell'; 15 | import Checkbox from '@material-ui/core/Checkbox'; 16 | import IconButton from '@material-ui/core/IconButton'; 17 | import Dialog from '@material-ui/core/Dialog'; 18 | import DialogTitle from '@material-ui/core/DialogTitle'; 19 | import DialogContent from '@material-ui/core/DialogContent'; 20 | import DialogContentText from '@material-ui/core/DialogContentText'; 21 | import DialogActions from '@material-ui/core/DialogActions'; 22 | import EditIcon from '@material-ui/icons/Edit'; 23 | import DeleteIcon from '@material-ui/icons/Delete'; 24 | 25 | import * as actions from './actions'; 26 | import * as selectors from './selectors'; 27 | import reducer from './reducer'; 28 | 29 | const useStyles = makeStyles(theme => ({ 30 | root: { 31 | width: '100%', 32 | 'margin-top': theme.spacing(3), 33 | }, 34 | 'o-toolbar-selected': { 35 | color: '#ff5252', 36 | }, 37 | 'o-spacer': { 38 | flex: '1 0 auto', 39 | }, 40 | table: {}, 41 | tableWrapper: { 42 | 'overflow-x': 'auto', 43 | }, 44 | })); 45 | 46 | export const Basic = () => { 47 | const classes = useStyles(); 48 | const b$ = useSelector(state => state.crudOperations.basic); 49 | const numSelected = useSelector(selectors.numSelected); 50 | const dispatch = useDispatch(); 51 | 52 | return ( 53 |
    54 | CRUD Operations - Basic 55 | 56 | 60 | dispatch( 61 | actions.setData({ 62 | addData: { ...b$.addData, primary: event.target.value }, 63 | }), 64 | ) 65 | } 66 | /> 67 | 71 | dispatch( 72 | actions.setData({ 73 | addData: { ...b$.addData, accent: event.target.value }, 74 | }), 75 | ) 76 | } 77 | /> 78 | 91 | 92 | 93 | {numSelected !== 0 ? ( 94 | 95 | 96 | {numSelected} selected 97 | 98 |
    99 | { 101 | await b$.selected.forEach(({ id }) => 102 | dispatch(actions.deleteItem(id)), 103 | ); 104 | await dispatch(actions.setData({ selected: [] })); 105 | }} 106 | > 107 | 108 | 109 | 110 | ) : ( 111 | 112 | Board 113 | 117 | dispatch(actions.setData({ searchData: event.target.value })) 118 | } 119 | /> 120 | 121 | )} 122 | 123 | 124 | 125 | 126 | 127 | 0 && 130 | b$.selected.length < b$.dataset.length 131 | } 132 | checked={ 133 | b$.selected.length === b$.dataset.length && 134 | b$.dataset.length !== 0 135 | } 136 | onChange={(event, checked) => 137 | dispatch( 138 | actions.setData({ 139 | selected: checked ? b$.dataset.map(item => item) : [], 140 | }), 141 | ) 142 | } 143 | /> 144 | 145 | ID 146 | Primary 147 | Accent 148 | Actions 149 | 150 | 151 | 152 | {b$.dataset 153 | .filter( 154 | item => 155 | item.primary 156 | .toLowerCase() 157 | .indexOf(b$.searchData.toLowerCase()) > -1 || 158 | item.accent 159 | .toLowerCase() 160 | .indexOf(b$.searchData.toLowerCase()) !== -1, 161 | ) 162 | .map(item => ( 163 | 164 | 165 | { 168 | // FIXME: When editing is complete, it should not be unchecked. 169 | let res = []; 170 | 171 | const index = b$.selected.indexOf(item); 172 | if (index === -1) res = res.concat(b$.selected, item); 173 | if (index === 0) res = res.concat(b$.selected.slice(1)); 174 | if (index === b$.selected.length - 1) 175 | res = res.concat(b$.selected.slice(0, -1)); 176 | if (index > 0) 177 | res = res.concat( 178 | b$.selected.slice(0, index), 179 | b$.selected.slice(index + 1), 180 | ); 181 | 182 | dispatch(actions.setData({ selected: res })); 183 | }} 184 | /> 185 | 186 | {item.id} 187 | {item.primary} 188 | {item.accent} 189 | 190 | 192 | dispatch( 193 | actions.setData({ 194 | editData: { 195 | ...b$.editData, 196 | id: item.id, 197 | primary: item.primary, 198 | accent: item.accent, 199 | }, 200 | dialogs: { ...b$.dialogs, edit: true }, 201 | }), 202 | ) 203 | } 204 | > 205 | 206 | 207 | 209 | dispatch( 210 | actions.setData({ 211 | deleteData: { ...b$.deleteData, id: item.id }, 212 | dialogs: { ...b$.dialogs, delete: true }, 213 | }), 214 | ) 215 | } 216 | > 217 | 218 | 219 | 220 | 221 | ))} 222 | 223 |
    224 | 225 | 226 | 229 | dispatch(actions.setData({ dialogs: { ...b$.dialogs, edit: false } })) 230 | } 231 | > 232 | Edit 233 | 234 | 235 | 239 | dispatch( 240 | actions.setData({ 241 | editData: { ...b$.editData, primary: event.target.value }, 242 | }), 243 | ) 244 | } 245 | /> 246 | 250 | dispatch( 251 | actions.setData({ 252 | editData: { ...b$.editData, accent: event.target.value }, 253 | }), 254 | ) 255 | } 256 | /> 257 | 258 | 259 | 260 | 269 | 281 | 282 | 283 | 284 | 287 | dispatch( 288 | actions.setData({ dialogs: { ...b$.dialogs, delete: false } }), 289 | ) 290 | } 291 | > 292 | Delete 293 | 294 | 295 | Are you sure you want to delete it? 296 | 297 | 298 | 299 | 308 | 320 | 321 | 322 |
    323 | ); 324 | }; 325 | 326 | export default dynamic(['crudOperations', 'basic'], reducer)(Basic); 327 | -------------------------------------------------------------------------------- /src/shell/crud-operations/basic/actions.js: -------------------------------------------------------------------------------- 1 | import { ADD_ITEM, EDIT_ITEM, DELETE_ITEM, SET_DATA } from './constants'; 2 | 3 | export const addItem = payload => ({ type: ADD_ITEM, payload }); 4 | export const editItem = payload => ({ type: EDIT_ITEM, payload }); 5 | export const deleteItem = id => ({ type: DELETE_ITEM, id }); 6 | 7 | export const setData = data => ({ type: SET_DATA, data }); 8 | -------------------------------------------------------------------------------- /src/shell/crud-operations/basic/constants.js: -------------------------------------------------------------------------------- 1 | export const INITIAL = { 2 | searchData: '', 3 | selected: [], 4 | 5 | dataset: [ 6 | { id: 1, primary: 'Vanilla', accent: 'MobX' }, 7 | { id: 2, primary: 'Angular', accent: 'NGXS' }, 8 | { id: 3, primary: 'React', accent: 'Redux' }, 9 | { id: 4, primary: 'Vue', accent: 'Vuex' }, 10 | ], 11 | 12 | addData: {}, 13 | editData: {}, 14 | viewData: {}, 15 | deleteData: {}, 16 | 17 | dialogs: { 18 | add: false, 19 | edit: false, 20 | view: false, 21 | delete: false, 22 | }, 23 | }; 24 | 25 | export const ADD_ITEM = '[crudOperations/basic] ADD_ITEM'; 26 | export const EDIT_ITEM = '[crudOperations/basic] EDIT_ITEM'; 27 | export const DELETE_ITEM = '[crudOperations/basic] DELETE_ITEM'; 28 | 29 | export const SET_DATA = '[crudOperations/basic] SET_DATA'; 30 | -------------------------------------------------------------------------------- /src/shell/crud-operations/basic/reducer.js: -------------------------------------------------------------------------------- 1 | import { handleActions } from 'redux-actions'; 2 | 3 | import { INITIAL, ADD_ITEM, EDIT_ITEM, DELETE_ITEM, SET_DATA } from './constants'; 4 | 5 | export default handleActions({ 6 | [ADD_ITEM](state, { payload: { primary, accent } }) { 7 | const id = state.dataset.reduce((maxId, item) => Math.max(item.id, maxId), -1) + 1; 8 | 9 | return { 10 | ...state, 11 | dataset: [...state.dataset, { id, primary, accent }], 12 | }; 13 | }, 14 | [EDIT_ITEM](state, { payload: { id, primary, accent } }) { 15 | return { 16 | ...state, 17 | dataset: [ 18 | ...state.dataset 19 | .map(item => (item.id === id ? { ...item, primary, accent } : item)), 20 | ], 21 | }; 22 | }, 23 | [DELETE_ITEM](state, { id }) { 24 | const removeById = arr => ( 25 | [...arr.filter(item => item.id !== id)] 26 | ); 27 | 28 | return { 29 | ...state, 30 | dataset: removeById(state.dataset), 31 | selected: state.selected.length ? removeById(state.selected) : state.selected, 32 | }; 33 | }, 34 | [SET_DATA](state, { data }) { 35 | return { ...state, ...data }; 36 | }, 37 | }, INITIAL); 38 | -------------------------------------------------------------------------------- /src/shell/crud-operations/basic/selectors.js: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect'; 2 | 3 | export const numSelected = createSelector( 4 | state => state.crudOperations.basic, 5 | ({ selected }) => selected.length, 6 | ); 7 | -------------------------------------------------------------------------------- /src/shell/crud-operations/reducer.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | 3 | import basic from './basic/reducer'; 4 | 5 | export default combineReducers({ 6 | basic, 7 | }); 8 | -------------------------------------------------------------------------------- /src/shell/github-repos/GithubRepos.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useCallback } from 'react'; 2 | import styled from '@emotion/styled'; 3 | import { debounce } from 'lodash'; 4 | import axios from 'axios'; 5 | 6 | import InfiniteScroll from './InfiniteScroll'; 7 | 8 | const GITHUB_API = 'https://api.github.com'; 9 | 10 | const Title = styled('h2')` 11 | color: #222222; 12 | `; 13 | 14 | const Input = styled('input')` 15 | outline: 0; 16 | background: #f2f2f2; 17 | width: 100%; 18 | border: 0; 19 | margin: 0 0 15px; 20 | padding: 15px; 21 | box-sizing: border-box; 22 | font-size: 14px; 23 | `; 24 | 25 | const GithubRepos = () => { 26 | const [searchVal, setSearchVal] = useState(''); 27 | const [repoList, setRepoList] = useState([]); 28 | const [hasMore, setHasMore] = useState(false); 29 | const [resetPage, setResetPage] = useState(false); 30 | const [isLoading, setIsLoading] = useState(false); 31 | 32 | const debounceSearch = useCallback( 33 | debounce(val => { 34 | axios 35 | .get(`${GITHUB_API}/search/repositories`, { 36 | params: { page: 1, q: val }, 37 | }) 38 | .then(res => { 39 | setRepoList(res.data.items); 40 | setIsLoading(false); 41 | setResetPage(false); 42 | if (res.data.items.length < 30) { 43 | setHasMore(false); 44 | } else { 45 | setHasMore(true); 46 | } 47 | }); 48 | }, 500), 49 | [], 50 | ); 51 | 52 | const loadMore = page => { 53 | setIsLoading(true); 54 | 55 | axios 56 | .get(`${GITHUB_API}/search/repositories`, { 57 | params: { page, q: searchVal }, 58 | }) 59 | .then(res => { 60 | setRepoList([...repoList, ...res.data.items]); 61 | setHasMore(true); 62 | setIsLoading(false); 63 | if (res.data.items.length < 30) setHasMore(false); 64 | }); 65 | }; 66 | 67 | return ( 68 | <> 69 | Github Repos 70 | 71 | { 74 | if (evt.target.value) { 75 | setResetPage(true); 76 | setRepoList([]); 77 | setIsLoading(true); 78 | setSearchVal(evt.target.value); 79 | debounceSearch(evt.target.value); 80 | } 81 | }} 82 | /> 83 | 84 | 91 | {!!repoList.length && 92 | repoList.map(repo => ( 93 |
  • 94 | {repo.name} 95 |
    96 | {repo.html_url} 97 |
  • 98 | ))} 99 | {isLoading &&
    Loading...
    } 100 |
    101 | 102 | ); 103 | }; 104 | 105 | export default GithubRepos; 106 | -------------------------------------------------------------------------------- /src/shell/github-repos/InfiniteScroll.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | export default class InfiniteScroll extends Component { 5 | static propTypes = { 6 | children: PropTypes.node.isRequired, 7 | ref: PropTypes.func, 8 | element: PropTypes.node, 9 | pageStart: PropTypes.number, 10 | hasMore: PropTypes.bool, 11 | loadMore: PropTypes.func.isRequired, 12 | resetPage: PropTypes.bool, 13 | threshold: PropTypes.number, 14 | }; 15 | 16 | static defaultProps = { 17 | ref: null, 18 | element: 'div', 19 | pageStart: 0, 20 | hasMore: false, 21 | resetPage: false, 22 | threshold: 250, 23 | }; 24 | 25 | scrollComponent = null; 26 | 27 | pageLoaded = null; 28 | 29 | componentDidMount() { 30 | const { pageStart } = this.props; 31 | this.pageLoaded = pageStart; 32 | this.attachScrollListener(); 33 | } 34 | 35 | componentDidUpdate() { 36 | const { pageStart, resetPage } = this.props; 37 | this.attachScrollListener(); 38 | 39 | if (resetPage) { 40 | this.pageLoaded = pageStart; 41 | window.scrollTo(0, 0); 42 | } 43 | } 44 | 45 | componentWillUnmount() { 46 | this.detachScrollListener(); 47 | } 48 | 49 | attachScrollListener = () => { 50 | const { hasMore } = this.props; 51 | if (!hasMore) return; 52 | window.addEventListener('scroll', this.scrollListener); 53 | }; 54 | 55 | detachScrollListener = () => { 56 | window.removeEventListener('scroll', this.scrollListener); 57 | }; 58 | 59 | scrollListener = () => { 60 | const { threshold, hasMore, loadMore } = this.props; 61 | const el = this.scrollComponent; 62 | const offset = this.calculateOffset(el, window.pageYOffset); 63 | 64 | if (offset < threshold && (el && el.offsetParent !== null)) { 65 | this.detachScrollListener(); 66 | 67 | if (hasMore && typeof loadMore === 'function') { 68 | loadMore((this.pageLoaded += 1)); 69 | } 70 | } 71 | }; 72 | 73 | calculateOffset = (el, scrollTop) => { 74 | if (!el) return 0; 75 | 76 | return ( 77 | this.calculateTopPosition(el) + 78 | (el.offsetHeight - scrollTop - window.innerHeight) 79 | ); 80 | }; 81 | 82 | calculateTopPosition = el => { 83 | if (!el) return 0; 84 | return el.offsetTop + this.calculateTopPosition(el.offsetParent); 85 | }; 86 | 87 | render() { 88 | const { 89 | children, 90 | ref, 91 | element, 92 | pageStart, 93 | hasMore, 94 | loadMore, 95 | resetPage, 96 | threshold, 97 | ...props 98 | } = this.props; 99 | 100 | props.ref = node => { 101 | this.scrollComponent = node; 102 | if (ref) ref(node); 103 | }; 104 | 105 | return React.createElement(element, props, [children]); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/shell/hello-world/HelloWorld.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { useSelector } from 'react-redux'; 3 | import { dynamic } from 'redux-dynamic-manager'; 4 | import styled from '@emotion/styled'; 5 | 6 | import reducer from './reducer'; 7 | 8 | const Title = styled('h1')` 9 | color: #222; 10 | `; 11 | 12 | const HelloWorld = () => { 13 | const [world] = useState('World'); 14 | const { hello } = useSelector(state => state.helloWorld); 15 | 16 | return ( 17 |
    18 | {hello}, {world}! 19 |
    20 | ); 21 | }; 22 | 23 | export default dynamic('helloWorld', reducer)(HelloWorld); 24 | -------------------------------------------------------------------------------- /src/shell/hello-world/__tests__/HelloWorld.e2e-spec.js: -------------------------------------------------------------------------------- 1 | import puppeteer from 'puppeteer'; 2 | 3 | describe('HelloWorld', () => { 4 | let [page, browser] = []; 5 | 6 | beforeAll(async () => { 7 | browser = await puppeteer.launch(global.launch); 8 | page = await browser.newPage(); 9 | await page.setViewport(global.viewport); 10 | }); 11 | 12 | afterAll(async () => { 13 | await browser.close(); 14 | }); 15 | 16 | beforeEach(async () => { 17 | await page.goto(`${global.SITE_URL}/hello-world`); 18 | }); 19 | 20 | it('should display a text', async () => { 21 | const selector = '#hello-world > h1'; 22 | const text = await page.$eval(selector, el => el.textContent); 23 | expect(text).toMatch('Hello, World!'); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/shell/hello-world/__tests__/HelloWorld.spec.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createStore } from 'redux'; 3 | import { Provider } from 'react-redux'; 4 | import { render } from '@testing-library/react'; 5 | 6 | import HelloWorld from '../HelloWorld'; 7 | 8 | import { INITIAL } from '../constants'; 9 | import reducer from '../reducer'; 10 | 11 | describe('HelloWorld', () => { 12 | it('should render an initial component', () => { 13 | const store = createStore(reducer, { helloWorld: { ...INITIAL } }); 14 | const { container } = render(); 15 | expect(container).toMatchSnapshot(); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/shell/hello-world/__tests__/__snapshots__/HelloWorld.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`HelloWorld should render an initial component 1`] = ` 4 | .emotion-0 { 5 | color: #222; 6 | } 7 | 8 |
    9 |
    12 |

    15 | Hello 16 | , 17 | World 18 | ! 19 |

    20 |
    21 |
    22 | `; 23 | -------------------------------------------------------------------------------- /src/shell/hello-world/constants.js: -------------------------------------------------------------------------------- 1 | export const INITIAL = { 2 | hello: 'Hello', 3 | }; 4 | 5 | export const SET_DATA = '[helloWorld] SET_DATA'; 6 | -------------------------------------------------------------------------------- /src/shell/hello-world/reducer.js: -------------------------------------------------------------------------------- 1 | import { handleActions } from 'redux-actions'; 2 | 3 | import assignDeep from '~/shared/assign-deep'; 4 | 5 | import { INITIAL, SET_DATA } from './constants'; 6 | 7 | export default handleActions({ 8 | [SET_DATA](state, { data }) { 9 | return { ...assignDeep(state, data) }; 10 | }, 11 | }, INITIAL); 12 | -------------------------------------------------------------------------------- /src/shell/markdown-editor/ArticleDetail.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React from 'react'; 4 | import { useSelector, useDispatch } from 'react-redux'; 5 | import { dynamic } from 'redux-dynamic-manager'; 6 | import useRouter from 'use-react-router'; 7 | import useForm from 'react-hook-form'; 8 | import Markdown from 'react-markdown'; 9 | 10 | import { setData } from './actions'; 11 | import reducer from './reducer'; 12 | import { ErrorText } from './MarkdownEditor'; 13 | 14 | const ArticleDetail = () => { 15 | const { articleList, articleDetail } = useSelector(state => state.markdownEditor); 16 | const dispatch = useDispatch(); 17 | const router = useRouter(); 18 | const { handleSubmit, register, errors } = useForm(); 19 | 20 | const onSubmit = () => { 21 | dispatch( 22 | setData({ 23 | articleList: [ 24 | ...articleList 25 | .map(item => (item.id === articleDetail.id ? { ...articleDetail } : item)), 26 | ], 27 | }), 28 | ); 29 | 30 | router.history.push('/markdown-editor'); 31 | }; 32 | 33 | return ( 34 | <> 35 |
    36 |
    37 | Subject*: 38 | { 43 | dispatch( 44 | setData({ 45 | articleDetail: { subject: evt.target.value }, 46 | }), 47 | ); 48 | }} 49 | /> 50 | {errors.subject && errors.subject.message} 51 |
    52 | 53 |
    54 | Content* 55 |