├── .babelrc ├── .dockerignore ├── .eslintrc.json ├── .gitignore ├── .sass-lint.yml ├── Dockerfile ├── LICENSE ├── PLAN.md ├── README.md ├── TOOLS-DOCKER.md ├── TOOLS-LOCAL.md ├── circle.yml ├── firebase.json ├── misc ├── nginx.conf └── testSetup.js ├── package.json ├── resources ├── Google-Chrome-icon.png ├── actions.ex.png ├── components-tree.1.png ├── components.tree.2.png ├── container-react-redux.ex.png ├── css-mdoule.ex.2.html ├── css-module.ex.1.png ├── css-module.ex.2.png ├── css-module.ex.3.png ├── css-modules-logo.png ├── devtools-full.gif ├── devtools-window.png ├── firebase.ex.png ├── image00.png ├── logo_chromium.png ├── normalizr.png ├── react-redux.png ├── react.ex.png ├── react.png ├── reducers.ex.png ├── redux-devtools.png ├── redux-little-router.ex.png ├── redux-little-router.png ├── redux-thunk.png ├── redux.png ├── reselect.ex.png ├── reselect.png ├── sass-logo.png ├── sass-tvshow-card.png ├── sass.ex.png ├── tvscrub-home.png └── tvscrubs-tvshow-detail.png ├── src ├── components │ ├── App.jsx │ ├── Appbar │ │ ├── Home │ │ │ ├── home.container.js │ │ │ ├── home.jsx │ │ │ └── index.js │ │ ├── Search │ │ │ ├── index.js │ │ │ ├── search.actions.js │ │ │ ├── search.actions.spec.js │ │ │ ├── search.container.js │ │ │ ├── search.jsx │ │ │ └── search.style.scss │ │ ├── User │ │ │ ├── Avatar │ │ │ │ ├── avatar.container.js │ │ │ │ ├── avatar.jsx │ │ │ │ ├── avatar.style.scss │ │ │ │ └── index.js │ │ │ ├── index.js │ │ │ ├── user.actions.js │ │ │ ├── user.container.js │ │ │ ├── user.jsx │ │ │ └── user.style.scss │ │ ├── appbar.jsx │ │ ├── appbar.style.scss │ │ └── index.js │ ├── Results │ │ ├── Result │ │ │ ├── Bar │ │ │ │ ├── bar.container.js │ │ │ │ ├── bar.jsx │ │ │ │ ├── bar.style.scss │ │ │ │ └── index.js │ │ │ ├── Overlay │ │ │ │ ├── Add │ │ │ │ │ ├── add.actions.js │ │ │ │ │ ├── add.container.js │ │ │ │ │ ├── add.jsx │ │ │ │ │ └── index.js │ │ │ │ ├── index.js │ │ │ │ ├── overlay.container.js │ │ │ │ ├── overlay.jsx │ │ │ │ └── overlay.style.scss │ │ │ ├── index.js │ │ │ ├── result.container.js │ │ │ ├── result.jsx │ │ │ └── result.style.scss │ │ ├── index.js │ │ ├── results.actions.js │ │ ├── results.container.js │ │ ├── results.jsx │ │ └── results.style.scss │ └── TVShow │ │ ├── Episodes │ │ ├── Episode │ │ │ ├── ToHere │ │ │ │ ├── index.js │ │ │ │ ├── tohere.actions.js │ │ │ │ ├── tohere.container.js │ │ │ │ └── tohere.jsx │ │ │ ├── UnView │ │ │ │ ├── index.js │ │ │ │ ├── unview.actions.js │ │ │ │ ├── unview.container.js │ │ │ │ └── unview.jsx │ │ │ ├── View │ │ │ │ ├── index.js │ │ │ │ ├── view.actions.js │ │ │ │ ├── view.container.js │ │ │ │ └── view.jsx │ │ │ ├── episode.container.js │ │ │ ├── episode.jsx │ │ │ ├── episode.selectors.js │ │ │ ├── episode.style.scss │ │ │ └── index.js │ │ ├── episodes.container.js │ │ ├── episodes.jsx │ │ ├── episodes.selectors.js │ │ ├── episodes.selectors.spec.js │ │ ├── episodes.style.scss │ │ └── index.js │ │ ├── index.js │ │ ├── tvshow.actions.js │ │ ├── tvshow.container.js │ │ ├── tvshow.jsx │ │ ├── tvshow.selectors.js │ │ └── tvshow.style.scss ├── global.scss ├── index.html ├── index.jsx ├── redux │ ├── constants.js │ ├── defaults.js │ ├── episodes │ │ ├── episodes.actions.js │ │ ├── episodes.js │ │ ├── episodes.selectors.js │ │ └── index.js │ ├── results │ │ ├── index.js │ │ ├── results.actions.js │ │ ├── results.js │ │ └── results.selectors.js │ ├── router │ │ ├── index.js │ │ ├── router.js │ │ └── router.selectors.js │ ├── search │ │ ├── index.js │ │ ├── search.actions.js │ │ ├── search.actions.spec.js │ │ ├── search.js │ │ ├── search.selectors.js │ │ └── search.spec.js │ ├── seen │ │ ├── index.js │ │ ├── seen.actions.js │ │ ├── seen.js │ │ └── seen.selectors.js │ ├── store.js │ ├── tvshows │ │ ├── episodes │ │ │ ├── episodes.actions.js │ │ │ ├── episodes.js │ │ │ └── index.js │ │ ├── index.js │ │ ├── tvshows.actions.js │ │ ├── tvshows.js │ │ └── tvshows.selectors.js │ └── user │ │ ├── index.js │ │ ├── user.actions.js │ │ ├── user.js │ │ └── user.selectors.js └── styles │ ├── _box-shadow.scss │ └── _colors.scss └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["transform-class-properties", "transform-object-rest-spread"], 3 | "presets": ["es2017", "es2015", "react", "stage-0"] 4 | } 5 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | src 4 | public 5 | yarn.lock 6 | resources 7 | Dockerfile 8 | *.md 9 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": ["airbnb"], 4 | "globals": { 5 | "fetch": false, 6 | "firebase": false 7 | }, 8 | "rules": { 9 | "semi": [2, "never"], 10 | "arrow-body-style": 0, 11 | "import/no-named-as-default": 0, 12 | "import/prefer-default-export": 0, 13 | "import/no-unresolved": 0, 14 | "react/forbid-prop-types": 0 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | yarn.lock 36 | 37 | # Optional REPL history 38 | .node_repl_history 39 | 40 | # Custom 41 | public 42 | -------------------------------------------------------------------------------- /.sass-lint.yml: -------------------------------------------------------------------------------- 1 | rules: 2 | extends-before-mixins: 2 3 | extends-before-declarations: 2 4 | placeholder-in-extend: 0 5 | hex-notation: 2 6 | indentation: 2 7 | property-sort-order: 2 8 | variable-for-property: 2 9 | no-color-literals: 2 10 | clean-import-paths: 2 11 | hex-length: 2 12 | force-pseudo-nesting: 2 13 | force-element-nesting: 2 14 | quotes: 2 15 | class-name-format: 0 16 | empty-line-between-blocks: 2 17 | leading-zero: 2 18 | space-before-brace: 2 19 | space-before-bang: 2 20 | no-important: 0 21 | no-css-comments: 2 22 | no-color-keywords: 2 23 | space-after-colon: 2 24 | no-vendor-prefixes: 0 25 | border-zero: 2 26 | no-empty-rulesets: 2 27 | trailing-semicolon: 2 28 | no-duplicate-properties: 2 29 | shorthand-values: 2 30 | pseudo-element: 2 31 | single-line-per-selector: 2 32 | zero-unit: 2 33 | no-mergeable-selectors: 2 34 | no-qualifying-elements: 2 35 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM zenika/alpine-node:onbuild 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Fabien JUIF 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 | -------------------------------------------------------------------------------- /PLAN.md: -------------------------------------------------------------------------------- 1 | [20] init environnement 2 | 3 | [15] react: juste un composant (Avatar de login) 4 | Exemple: Appbar/User 5 | 6 | [15] redux-1 7 | Exemple: Appbar/Search 8 | Actions et Reducers 9 | - Via des tests à exécuter 10 | 11 | [15] redux-2 12 | Exemple: Appbar/Search 13 | - Container 14 | - Debug de l'applications 15 | 16 | [15] redux-thunk 17 | Exemple: Appbar/Search 18 | - TU 19 | 20 | [15] redux-little-router 21 | Exemple: Result 22 | 23 | -- En option, si le temps -- 24 | [15] reselect 25 | Exemple: episodes.selectors 26 | - TU 27 | 28 | -- 29 | 30 | [10] Firebase 31 | Exemple: Juste le déploiement 32 | - Créer le compte 33 | - Récupérer le token, etc 34 | 35 | -- Bonus si temps -- 36 | 37 | [15] Firebase: Refaire le bouton "Pas vu" 38 | 39 | -- 40 | 41 | -- Bonus, pas en codelab, juste à l'oral si temps -- 42 | 43 | [15] Sass 44 | 45 | -- 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-redux-codelab: tvscrub 2 | In this codelab we will build a tvshows tracker with react, redux and firebase. 3 | 4 | You can find an instance here to play with: [react-redux-codelab (hosted by Firebase)](https://react-redux-codelab.firebaseapp.com/) 5 | 6 | ![Imgur](http://i.imgur.com/FsJFZZl.png) 7 | 8 | ![Imgur](http://i.imgur.com/TCty3IV.png) 9 | 10 | 11 | ## Slides 12 | 1. Tools and libs: [Google doc -fr-](https://docs.google.com/presentation/d/1NlW5g9BY4QHIgyGbQqZxWtR3KugYmyUvSUAsHezmCo0/edit?usp=sharing) 13 | 2. Components and redux state: [Google doc -fr-](https://docs.google.com/presentation/d/1MfxJQWou7iEe9Il5MaYCqhEH4LYRBZKTClbPEN__zjc/edit?usp=sharing) 14 | 3. Steps: [PLAN -fr-](./PLAN.md) 15 | 4. Steps (detail): [Google doc -fr-](https://docs.google.com/presentation/d/1qU1pqq3TXb-0jLTKToKLcKremc8A6sVqXknNzGxdrhU/edit?usp=sharing) 16 | 17 | ## Steps 18 | For full steps, you can look up to the Google doc. 19 | 20 | This steps are summaries: 21 | * React 22 | * How do we create a react component (as pure function) 23 | * Redux 24 | * How do we create a reducer and an action with redux 25 | * react-redux 26 | * How do we connect our component to our state/actions 27 | * redux-thunk 28 | * How do we dispatch asynchronous actions 29 | * redux-little-router 30 | * How do we activate browser navigation 31 | * This is also the time to retrieve some URL options (route params) 32 | * reselect 33 | * How to we optimize our container (react-redux) 34 | * firebase 35 | * How do we deploy the application on your own account 36 | 37 | ## Get tools 38 | Tools and dependencies that are **NEEDED**, choose between: 39 | * [Install everything locally](./TOOLS-LOCAL.md) 40 | * [Use docker](./TOOLS-DOCKER.md) 41 | 42 | You should install these tools, those are must-haves (but are optionals): 43 | * [google-chrome](https://www.google.fr/chrome/browser/desktop/) 44 | * [redux-devtools](https://chrome.google.com/webstore/detail/redux-devtools/lmhkpmbekcpmknklioeibfkpmmfibljd) 45 | * [react-devtools](https://chrome.google.com/webstore/detail/react-developer-tools/fmkadmapgofadopljbjfkapdkoienihi) 46 | * [Atom](https://atom.io/) 47 | * Plugins: 48 | * highlight-selected 49 | * file-icons 50 | * react 51 | * pigment 52 | * linter-eslint 53 | * linter-sass-lint 54 | 55 | 56 | -------------------------------------------------------------------------------- /TOOLS-DOCKER.md: -------------------------------------------------------------------------------- 1 | ## Docker 2 | You don't want to have to install node, npm, firebase, etc: You can use docker. 3 | 4 | Download the following images: 5 | ```bash 6 | # Dependencies, npm and node 7 | docker pull fabienjuif/react-redux-codelab 8 | # Firebase CLI 9 | docker pull devillex/docker-firebase 10 | ``` 11 | 12 | ### Commands 13 | * start a local server (dev) with hot reloading (http://localhost:3000) 14 | ```bash 15 | docker run -it --rm \ 16 | -p 3000:3000 \ 17 | -v ${PWD}/src:/usr/src/app/src \ 18 | fabienjuif/react-redux-codelab \ 19 | npm start 20 | ``` 21 | * run tests 22 | ```bash 23 | docker run -it --rm \ 24 | -v ${PWD}/src:/usr/src/app/src \ 25 | fabienjuif/react-redux-codelab \ 26 | npm test 27 | ``` 28 | * run linters 29 | ```bash 30 | docker run -it --rm \ 31 | -v ${PWD}/src:/usr/src/app/src \ 32 | fabienjuif/react-redux-codelab \ 33 | npm run lint 34 | ``` 35 | * build the production bundle 36 | ```bash 37 | docker run -it --rm \ 38 | -v ${PWD}/public:/usr/src/app/public \ 39 | -v ${PWD}/src:/usr/src/app/src \ 40 | fabienjuif/react-redux-codelab \ 41 | npm run build 42 | ``` 43 | * firebase 44 | * get a firebase token: 45 | ```bash 46 | docker run -it --rm \ 47 | -p9005:9005 \ 48 | devillex/docker-firebase \ 49 | firebase login:ci 50 | # Open link to your browser 51 | # Connect with your google account 52 | # Get the TOKEN 53 | ``` 54 | * publish the application (need to build first): 55 | ```bash 56 | export FIREBASE_PROJECT= 57 | export FIREBASE_TOKEN= 58 | docker run -it --rm \ 59 | -v ${PWD}/public:/public \ 60 | -v ${PWD}/firebase.json:/firebase.json \ 61 | devillex/docker-firebase \ 62 | firebase deploy --token=${FIREBASE_TOKEN} --non-interactive --project ${FIREBASE_PROJECT} 63 | ``` 64 | -------------------------------------------------------------------------------- /TOOLS-LOCAL.md: -------------------------------------------------------------------------------- 1 | ## Local 2 | You wanna have everything installed to your local machine. 3 | Ok let's go. 4 | 5 | Install npm (>=3) and node (>=7). 6 | 7 | Then run the following commands: 8 | ```bash 9 | # Get a faster npm 10 | npm install -g yarn 11 | # Install project dependencies via package.json 12 | yarn 13 | # Install firebase-cli (needed to deploy) 14 | npm install -g firebase-cli 15 | ``` 16 | 17 | ### Commands 18 | * start a local server (dev) with hot reloading: `yarn start` (http://localhost:3000) 19 | * run tests: `yarn test` 20 | * run linters: `yarn lint` 21 | * build the production bundle: `yarn build` 22 | * firebase 23 | * get a firebase token: 24 | ```bash 25 | firebase login:ci 26 | # Open link to your browser 27 | # Connect with your google account 28 | # Get the TOKEN 29 | ``` 30 | * publish the application (need to build first): 31 | ```bash 32 | export FIREBASE_PROJECT= 33 | export FIREBASE_TOKEN= 34 | firebase deploy --token=${FIREBASE_TOKEN} --non-interactive --project ${FIREBASE_PROJECT} 35 | ``` 36 | 37 | # Windows 38 | Dear Windows users, [git-bash](https://git-for-windows.github.io/) should be enough to run the previous commands lines. 39 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | machine: 2 | services: 3 | - docker 4 | 5 | dependencies: 6 | override: 7 | - docker run --rm -v $(pwd):/usr/src/app zenika/alpine-node npm install 8 | 9 | test: 10 | override: 11 | - docker run --rm -v $(pwd):/usr/src/app zenika/alpine-node npm run test 12 | - docker run --rm -v $(pwd):/usr/src/app zenika/alpine-node npm run lint 13 | 14 | deployment: 15 | production: 16 | branch: master 17 | commands: 18 | - npm install -g firebase-tools 19 | - docker run --rm -v $(pwd):/usr/src/app zenika/alpine-node npm run build 20 | - firebase deploy --token=${FIREBASE_TOKEN} --non-interactive --project ${FIREBASE_PROJECT} 21 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "hosting": { 3 | "public": "public", 4 | "rewrites": [ 5 | { 6 | "source": "**", 7 | "destination": "/index.html" 8 | } 9 | ] 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /misc/nginx.conf: -------------------------------------------------------------------------------- 1 | worker_processes auto; 2 | 3 | events { 4 | worker_connections 1024; 5 | } 6 | 7 | http { 8 | include mime.types; 9 | default_type application/octet-stream; 10 | 11 | sendfile on; 12 | keepalive_timeout 70; 13 | 14 | ssl_session_cache shared:SSL:10m; 15 | ssl_session_timeout 10m; 16 | 17 | server { 18 | listen 80; 19 | server_name tvmaze-https.chocakai.org; 20 | 21 | location ~ /\.well-known/acme-challenge { allow all; root /srv/http/tvmaze-https.chocakai.org/; } 22 | 23 | return 301 https://$host$request_uri; 24 | } 25 | 26 | server { 27 | listen 443 ssl; 28 | server_name tvmaze-https.chocakai.org; 29 | 30 | ssl_certificate /etc/letsencrypt/live/tvmaze-https.chocakai.org/fullchain.pem; 31 | ssl_certificate_key /etc/letsencrypt/live/tvmaze-https.chocakai.org/privkey.pem; 32 | 33 | location ~ /\.well-known/acme-challenge { allow all; root /srv/http/tvmaze-https.chocakai.org/; } 34 | location ~ /\. { deny all; access_log off; log_not_found off; } 35 | 36 | location / { 37 | proxy_pass http://api.tvmaze.com/; 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /misc/testSetup.js: -------------------------------------------------------------------------------- 1 | import { jsdom } from 'jsdom' 2 | import chai from 'chai' 3 | 4 | global.document = jsdom('') 5 | global.window = document.defaultView 6 | global.navigator = global.window.navigator 7 | 8 | /** Configuring chai. */ 9 | chai.should() 10 | chai.config.includeStack = true 11 | chai.config.truncateThreshold = 0 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tvscrub", 3 | "version": "0.1.1", 4 | "description": "", 5 | "directories": { 6 | "doc": "doc" 7 | }, 8 | "scripts": { 9 | "start": "cross-env NODE_ENV=development webpack-dev-server --host 0.0.0.0 --inline --hot --port=3000 --history-api-fallback --watch-poll=1000", 10 | "build": "cross-env NODE_ENV=production webpack --define process.env.NODE_ENV='\"production\"' -p ", 11 | "lint": "eslint --ext js,jsx src && find ./src/ -iname \"*.scss\" -exec sass-lint -v -q {} +", 12 | "test": "cross-env NODE_ENV=test mocha --recursive --compilers js:babel-register --require ./misc/testSetup.js \"src/**/*.spec.js\" " 13 | }, 14 | "author": "Fabien JUIF", 15 | "license": "MIT", 16 | "devDependencies": { 17 | "autoprefixer": "^6.5.1", 18 | "babel-core": "~6.14.0", 19 | "babel-eslint": "~6.1.2", 20 | "babel-loader": "~6.2.4", 21 | "babel-plugin-syntax-class-properties": "~6.13.0", 22 | "babel-plugin-transform-class-properties": "~6.11.5", 23 | "babel-plugin-transform-object-rest-spread": "~6.8.0", 24 | "babel-polyfill": "~6.13.0", 25 | "babel-preset-es2015": "^6.9.0", 26 | "babel-preset-es2017": "^6.14.0", 27 | "babel-preset-react": "~6.11.1", 28 | "babel-preset-stage-0": "~6.5.0", 29 | "babel-register": "~6.14.0", 30 | "chai": "~3.5.0", 31 | "cross-env": "^3.1.3", 32 | "css-loader": "^0.25.0", 33 | "eslint": "^3.2.2", 34 | "eslint-config-airbnb": "^11.1.0", 35 | "eslint-plugin-import": "^1.12.0", 36 | "eslint-plugin-jsx-a11y": "^2.0.1", 37 | "eslint-plugin-react": "^6.0.0", 38 | "expect": "~1.20.2", 39 | "extract-text-webpack-plugin": "^1.0.1", 40 | "file-loader": "~0.9.0", 41 | "html-webpack-plugin": "^2.24.1", 42 | "jsdom": "^9.4.2", 43 | "mocha": "~3.0.2", 44 | "node-sass": "^3.7.0", 45 | "postcss-loader": "^1.1.0", 46 | "react-hot-loader": "~1.3.0", 47 | "sass-lint": "^1.8.2", 48 | "sass-loader": "^4.0.2", 49 | "sinon": "^1.17.6", 50 | "style-loader": "~0.13.1", 51 | "webpack": "~1.13.2", 52 | "webpack-dev-server": "~1.15.2" 53 | }, 54 | "dependencies": { 55 | "classnames": "^2.2.5", 56 | "firebase": "^3.4.0", 57 | "hoc-react-loader": "^3.0.0", 58 | "lodash": "^4.14.2", 59 | "muicss": "^0.8.0", 60 | "react": "15.3.1", 61 | "react-dom": "15.3.1", 62 | "react-redux": "4.4.5", 63 | "react-router": "2.8.1", 64 | "redux": "~3.6.0", 65 | "redux-little-router": "^9.0.3", 66 | "redux-thunk": "~2.1.0", 67 | "reselect": "~2.5.2", 68 | "tinycolor2": "^1.4.1" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /resources/Google-Chrome-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabienjuif/react-redux-codelab/83f8c440babe40b02baea7f73bd56bc0dc3b6023/resources/Google-Chrome-icon.png -------------------------------------------------------------------------------- /resources/actions.ex.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabienjuif/react-redux-codelab/83f8c440babe40b02baea7f73bd56bc0dc3b6023/resources/actions.ex.png -------------------------------------------------------------------------------- /resources/components-tree.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabienjuif/react-redux-codelab/83f8c440babe40b02baea7f73bd56bc0dc3b6023/resources/components-tree.1.png -------------------------------------------------------------------------------- /resources/components.tree.2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabienjuif/react-redux-codelab/83f8c440babe40b02baea7f73bd56bc0dc3b6023/resources/components.tree.2.png -------------------------------------------------------------------------------- /resources/container-react-redux.ex.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabienjuif/react-redux-codelab/83f8c440babe40b02baea7f73bd56bc0dc3b6023/resources/container-react-redux.ex.png -------------------------------------------------------------------------------- /resources/css-mdoule.ex.2.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /resources/css-module.ex.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabienjuif/react-redux-codelab/83f8c440babe40b02baea7f73bd56bc0dc3b6023/resources/css-module.ex.1.png -------------------------------------------------------------------------------- /resources/css-module.ex.2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabienjuif/react-redux-codelab/83f8c440babe40b02baea7f73bd56bc0dc3b6023/resources/css-module.ex.2.png -------------------------------------------------------------------------------- /resources/css-module.ex.3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabienjuif/react-redux-codelab/83f8c440babe40b02baea7f73bd56bc0dc3b6023/resources/css-module.ex.3.png -------------------------------------------------------------------------------- /resources/css-modules-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabienjuif/react-redux-codelab/83f8c440babe40b02baea7f73bd56bc0dc3b6023/resources/css-modules-logo.png -------------------------------------------------------------------------------- /resources/devtools-full.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabienjuif/react-redux-codelab/83f8c440babe40b02baea7f73bd56bc0dc3b6023/resources/devtools-full.gif -------------------------------------------------------------------------------- /resources/devtools-window.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabienjuif/react-redux-codelab/83f8c440babe40b02baea7f73bd56bc0dc3b6023/resources/devtools-window.png -------------------------------------------------------------------------------- /resources/firebase.ex.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabienjuif/react-redux-codelab/83f8c440babe40b02baea7f73bd56bc0dc3b6023/resources/firebase.ex.png -------------------------------------------------------------------------------- /resources/image00.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabienjuif/react-redux-codelab/83f8c440babe40b02baea7f73bd56bc0dc3b6023/resources/image00.png -------------------------------------------------------------------------------- /resources/logo_chromium.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabienjuif/react-redux-codelab/83f8c440babe40b02baea7f73bd56bc0dc3b6023/resources/logo_chromium.png -------------------------------------------------------------------------------- /resources/normalizr.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabienjuif/react-redux-codelab/83f8c440babe40b02baea7f73bd56bc0dc3b6023/resources/normalizr.png -------------------------------------------------------------------------------- /resources/react-redux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabienjuif/react-redux-codelab/83f8c440babe40b02baea7f73bd56bc0dc3b6023/resources/react-redux.png -------------------------------------------------------------------------------- /resources/react.ex.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabienjuif/react-redux-codelab/83f8c440babe40b02baea7f73bd56bc0dc3b6023/resources/react.ex.png -------------------------------------------------------------------------------- /resources/react.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabienjuif/react-redux-codelab/83f8c440babe40b02baea7f73bd56bc0dc3b6023/resources/react.png -------------------------------------------------------------------------------- /resources/reducers.ex.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabienjuif/react-redux-codelab/83f8c440babe40b02baea7f73bd56bc0dc3b6023/resources/reducers.ex.png -------------------------------------------------------------------------------- /resources/redux-devtools.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabienjuif/react-redux-codelab/83f8c440babe40b02baea7f73bd56bc0dc3b6023/resources/redux-devtools.png -------------------------------------------------------------------------------- /resources/redux-little-router.ex.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabienjuif/react-redux-codelab/83f8c440babe40b02baea7f73bd56bc0dc3b6023/resources/redux-little-router.ex.png -------------------------------------------------------------------------------- /resources/redux-little-router.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabienjuif/react-redux-codelab/83f8c440babe40b02baea7f73bd56bc0dc3b6023/resources/redux-little-router.png -------------------------------------------------------------------------------- /resources/redux-thunk.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabienjuif/react-redux-codelab/83f8c440babe40b02baea7f73bd56bc0dc3b6023/resources/redux-thunk.png -------------------------------------------------------------------------------- /resources/redux.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabienjuif/react-redux-codelab/83f8c440babe40b02baea7f73bd56bc0dc3b6023/resources/redux.png -------------------------------------------------------------------------------- /resources/reselect.ex.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabienjuif/react-redux-codelab/83f8c440babe40b02baea7f73bd56bc0dc3b6023/resources/reselect.ex.png -------------------------------------------------------------------------------- /resources/reselect.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabienjuif/react-redux-codelab/83f8c440babe40b02baea7f73bd56bc0dc3b6023/resources/reselect.png -------------------------------------------------------------------------------- /resources/sass-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabienjuif/react-redux-codelab/83f8c440babe40b02baea7f73bd56bc0dc3b6023/resources/sass-logo.png -------------------------------------------------------------------------------- /resources/sass-tvshow-card.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabienjuif/react-redux-codelab/83f8c440babe40b02baea7f73bd56bc0dc3b6023/resources/sass-tvshow-card.png -------------------------------------------------------------------------------- /resources/sass.ex.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabienjuif/react-redux-codelab/83f8c440babe40b02baea7f73bd56bc0dc3b6023/resources/sass.ex.png -------------------------------------------------------------------------------- /resources/tvscrub-home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabienjuif/react-redux-codelab/83f8c440babe40b02baea7f73bd56bc0dc3b6023/resources/tvscrub-home.png -------------------------------------------------------------------------------- /resources/tvscrubs-tvshow-detail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabienjuif/react-redux-codelab/83f8c440babe40b02baea7f73bd56bc0dc3b6023/resources/tvscrubs-tvshow-detail.png -------------------------------------------------------------------------------- /src/components/App.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { RelativeFragment, AbsoluteFragment } from 'redux-little-router' 3 | import Results from './Results' 4 | import TVShow from './TVShow' 5 | import Appbar from './Appbar' 6 | 7 | const App = () => { 8 | return ( 9 |
10 | 11 | } /> 12 | } /> 13 |
14 | ) 15 | } 16 | 17 | export default App 18 | -------------------------------------------------------------------------------- /src/components/Appbar/Home/home.container.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux' 2 | import { setText } from 'redux/search' 3 | import Commponent from './home' 4 | 5 | const mapDispatchToProps = (dispatch) => { 6 | return { 7 | onClick: () => dispatch(setText('')), 8 | } 9 | } 10 | 11 | export default connect(undefined, mapDispatchToProps)(Commponent) 12 | -------------------------------------------------------------------------------- /src/components/Appbar/Home/home.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react' 2 | import Button from 'muicss/lib/react/button' 3 | import { Link } from 'redux-little-router' 4 | 5 | const Home = ({ className, onClick }) => { 6 | return ( 7 | 8 | 16 | 17 | ) 18 | } 19 | 20 | Home.propTypes = { 21 | className: PropTypes.string, 22 | onClick: PropTypes.func.isRequired, 23 | } 24 | 25 | export default Home 26 | -------------------------------------------------------------------------------- /src/components/Appbar/Home/index.js: -------------------------------------------------------------------------------- 1 | export default from './home.container' 2 | -------------------------------------------------------------------------------- /src/components/Appbar/Search/index.js: -------------------------------------------------------------------------------- 1 | export default from './search.container' 2 | -------------------------------------------------------------------------------- /src/components/Appbar/Search/search.actions.js: -------------------------------------------------------------------------------- 1 | import { PUSH } from 'redux-little-router' 2 | import { setText } from '../../../redux/search' 3 | import { addTVShow } from '../../../redux/tvshows' 4 | import { setResults } from '../../../redux/results' 5 | import { getTitle } from '../../../redux/router' 6 | import { API_URL } from '../../../redux/constants' 7 | 8 | export const fetchTVShows = text => (dispatch) => { 9 | fetch(`${API_URL}search/shows?q=${text}`) 10 | .then(raw => raw.json()) 11 | .then((results) => { 12 | dispatch(setResults(results.map(result => result.show.id))) 13 | 14 | results.forEach(result => dispatch(addTVShow(result.show))) 15 | }) 16 | } 17 | 18 | export const search = value => (dispatch, getState) => { 19 | dispatch(setText(value)) 20 | dispatch(fetchTVShows(value)) 21 | 22 | const title = getTitle(getState()) 23 | if (title !== 'HOME') { 24 | dispatch({ 25 | type: PUSH, 26 | payload: '/', 27 | }) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/components/Appbar/Search/search.actions.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | /* eslint-disable no-unused-expressions */ 3 | 4 | import _ from 'lodash' 5 | import { fetchTVShows } from './search.actions' 6 | 7 | describe('search actions', () => { 8 | describe('fetchTVShows action', () => { 9 | describe('Natures', () => { 10 | it('should be a function', () => { 11 | _.isFunction(fetchTVShows).should.be.true 12 | }) 13 | 14 | it('should return a new function', () => { 15 | const first = fetchTVShows('text') 16 | const second = fetchTVShows('text2') 17 | 18 | _.isFunction(first).should.be.true 19 | _.isFunction(second).should.be.true 20 | 21 | first.should.not.be.equals(second) 22 | }) 23 | }) 24 | }) 25 | 26 | describe('returned values', () => { 27 | beforeEach(() => { 28 | global.fetch = () => Promise.resolve({ 29 | data: '[{ "id": 1, "title": "first" },{ "id": 3, "title": "third" },{ "id": 2, "title": "second" }]', 30 | json: () => { 31 | return [ 32 | { show: { id: 1, title: 'first' } }, 33 | { show: { id: 3, title: 'third' } }, 34 | { show: { id: 2, title: 'second' } }, 35 | ] 36 | }, 37 | }) 38 | }) 39 | 40 | it('should dispatch a `setResults` action', (done) => { 41 | const embedded = fetchTVShows('text') 42 | let results = [] 43 | let called = false 44 | embedded((action) => { 45 | if (action.type === 'SET_RESULTS') { 46 | called = true 47 | results = action.payload 48 | } 49 | }) 50 | 51 | // Callback FIFO 52 | setTimeout(() => { 53 | called.should.be.true 54 | results.should.be.deep.equals([ 55 | 1, 56 | 3, 57 | 2, 58 | ]) 59 | 60 | done() 61 | }, 0) 62 | }) 63 | 64 | it('should dispatch some `addTVShow` action', (done) => { 65 | const embedded = fetchTVShows('text') 66 | const shows = [] 67 | let called = false 68 | embedded((action) => { 69 | if (action.type === 'ADD_TVSHOW') { 70 | called = true 71 | shows.push(action.payload) 72 | } 73 | }) 74 | 75 | // Callback FIFO 76 | setTimeout(() => { 77 | called.should.be.true 78 | shows.should.be.deep.equals([ 79 | { id: 1, title: 'first' }, 80 | { id: 3, title: 'third' }, 81 | { id: 2, title: 'second' }, 82 | ]) 83 | 84 | done() 85 | }, 0) 86 | }) 87 | }) 88 | }) 89 | 90 | /* eslint-enable no-unused-expressions */ 91 | -------------------------------------------------------------------------------- /src/components/Appbar/Search/search.container.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux' 2 | import { getText } from 'redux/search' 3 | import { search } from './search.actions' 4 | 5 | import Component from './search' 6 | 7 | const mapStateToProps = (state) => { 8 | return { 9 | value: getText(state), 10 | } 11 | } 12 | 13 | const mapDispatchToProps = (dispatch) => { 14 | return { 15 | onChange: event => dispatch(search(event.target.value)), 16 | } 17 | } 18 | 19 | export default connect(mapStateToProps, mapDispatchToProps)(Component) 20 | -------------------------------------------------------------------------------- /src/components/Appbar/Search/search.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react' 2 | import styles from './search.style' 3 | 4 | const Search = ({ className, ...rest }) => { 5 | return 6 | } 7 | 8 | Search.propTypes = { 9 | className: PropTypes.string, 10 | } 11 | 12 | export default Search 13 | -------------------------------------------------------------------------------- /src/components/Appbar/Search/search.style.scss: -------------------------------------------------------------------------------- 1 | @import '~styles/colors'; 2 | 3 | .search { 4 | background-color: $background-light; 5 | border: 0; 6 | border-radius: 2px; 7 | min-width: 180px; 8 | outline: none; 9 | padding: 5px 10px; 10 | width: 20%; 11 | } 12 | -------------------------------------------------------------------------------- /src/components/Appbar/User/Avatar/avatar.container.js: -------------------------------------------------------------------------------- 1 | import pick from 'lodash/pick' 2 | import { connect } from 'react-redux' 3 | import { getUser } from 'redux/user' 4 | import Component from './avatar' 5 | 6 | const mapStateToProps = (state) => { 7 | return { 8 | ...pick(getUser(state), ['photoURL']), 9 | } 10 | } 11 | 12 | export default connect(mapStateToProps)(Component) 13 | -------------------------------------------------------------------------------- /src/components/Appbar/User/Avatar/avatar.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react' 2 | import styles from './avatar.style' 3 | 4 | const Avatar = ({ style, className, photoURL = '' }) => { 5 | const ownStyle = { 6 | backgroundImage: `url(${photoURL})`, 7 | ...style, 8 | } 9 | 10 | return ( 11 |
12 | {photoURL !== '' || supervisor_account} 13 |
14 | ) 15 | } 16 | 17 | Avatar.propTypes = { 18 | style: PropTypes.object, 19 | className: PropTypes.string, 20 | photoURL: PropTypes.string, 21 | } 22 | 23 | export default Avatar 24 | -------------------------------------------------------------------------------- /src/components/Appbar/User/Avatar/avatar.style.scss: -------------------------------------------------------------------------------- 1 | @import '~styles/colors'; 2 | 3 | .avatar { 4 | align-items: center; 5 | background-color: $background-light; 6 | background-size: contain; 7 | border: 1px solid $white; 8 | border-radius: 50%; 9 | display: flex; 10 | height: 54px; 11 | justify-content: center; 12 | width: 54px; 13 | } 14 | -------------------------------------------------------------------------------- /src/components/Appbar/User/Avatar/index.js: -------------------------------------------------------------------------------- 1 | export default from './avatar.container' 2 | -------------------------------------------------------------------------------- /src/components/Appbar/User/index.js: -------------------------------------------------------------------------------- 1 | export default from './user.container' 2 | -------------------------------------------------------------------------------- /src/components/Appbar/User/user.actions.js: -------------------------------------------------------------------------------- 1 | import { setUser, resetUser, isConnected } from 'redux/user' 2 | 3 | /* global firebase */ 4 | export const login = () => (dispatch, getState) => { 5 | if (isConnected(getState())) return 6 | 7 | const provider = new firebase.auth.GoogleAuthProvider() 8 | 9 | firebase.auth().signInWithPopup(provider) 10 | .then(user => dispatch(setUser(user.user))) 11 | .catch(console.error) // eslint-disable-line no-console 12 | } 13 | 14 | export const retrieveLogin = () => (dispatch) => { 15 | firebase.auth().onAuthStateChanged((user) => { 16 | if (user) { 17 | dispatch(setUser(user)) 18 | } else { 19 | dispatch(resetUser()) 20 | } 21 | }) 22 | } 23 | -------------------------------------------------------------------------------- /src/components/Appbar/User/user.container.js: -------------------------------------------------------------------------------- 1 | import pick from 'lodash/pick' 2 | import { connect } from 'react-redux' 3 | import { getUser, isConnected } from 'redux/user' 4 | import Component from './user' 5 | import styles from './user.style' 6 | import { login, retrieveLogin } from './user.actions' 7 | 8 | const mapStateToProps = (state, { className = '' }) => { 9 | const user = getUser(state) 10 | const classNames = [className] 11 | if (!isConnected(state)) classNames.push(styles.login) 12 | 13 | return { 14 | ...pick(user, ['displayName']), 15 | className: classNames.join(' '), 16 | loaded: true, 17 | } 18 | } 19 | 20 | const mapDispatchToProps = (dispatch) => { 21 | return { 22 | onClick: () => dispatch(login()), 23 | load: () => dispatch(retrieveLogin()), 24 | } 25 | } 26 | 27 | export default connect(mapStateToProps, mapDispatchToProps)(Component) 28 | -------------------------------------------------------------------------------- /src/components/Appbar/User/user.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react' 2 | import loader from 'hoc-react-loader' 3 | import Avatar from './Avatar' 4 | import styles from './user.style' 5 | 6 | const User = ({ className, displayName, onClick }) => { 7 | return ( 8 | 12 | ) 13 | } 14 | 15 | User.propTypes = { 16 | className: PropTypes.string, 17 | displayName: PropTypes.string, 18 | onClick: PropTypes.func.isRequired, 19 | } 20 | 21 | export default loader(User, { Loader: null }) 22 | -------------------------------------------------------------------------------- /src/components/Appbar/User/user.style.scss: -------------------------------------------------------------------------------- 1 | .user { 2 | align-items: center; 3 | background: none; 4 | border: 0; 5 | display: flex; 6 | outline: none; 7 | 8 | .avatar { 9 | margin-left: 10px; 10 | } 11 | 12 | &.login { 13 | &:hover { 14 | cursor: pointer; 15 | 16 | .name { 17 | text-decoration: underline; 18 | } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/components/Appbar/appbar.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { default as MuiAppbar } from 'muicss/lib/react/appbar' 3 | import Search from './Search' 4 | import User from './User' 5 | import Home from './Home' 6 | import styles from './appbar.style' 7 | 8 | const Appbar = () => { 9 | return ( 10 | 11 | 12 | 13 | 14 | 15 | ) 16 | } 17 | 18 | export default Appbar 19 | -------------------------------------------------------------------------------- /src/components/Appbar/appbar.style.scss: -------------------------------------------------------------------------------- 1 | .appbar { 2 | align-items: center; 3 | display: flex; 4 | margin-bottom: 10px; 5 | 6 | .button { 7 | box-shadow: none; 8 | margin-left: 10px; 9 | } 10 | 11 | .search { 12 | margin-left: 10px; 13 | } 14 | 15 | .user { 16 | margin-left: auto; 17 | margin-right: 10px; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/components/Appbar/index.js: -------------------------------------------------------------------------------- 1 | export default from './appbar' 2 | -------------------------------------------------------------------------------- /src/components/Results/Result/Bar/bar.container.js: -------------------------------------------------------------------------------- 1 | import pick from 'lodash/pick' 2 | import { connect } from 'react-redux' 3 | import { helpers } from 'redux/tvshows' 4 | import Component from './bar' 5 | 6 | const mapDispatchToProps = (state, { id }) => { 7 | return { 8 | ...pick(helpers.getById(state, id), ['name']), 9 | } 10 | } 11 | 12 | export default connect(mapDispatchToProps)(Component) 13 | -------------------------------------------------------------------------------- /src/components/Results/Result/Bar/bar.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react' 2 | import Button from 'muicss/lib/react/button' 3 | import styles from './bar.style' 4 | 5 | const Bar = ({ name }) => { 6 | return ( 7 |
8 | {name} 9 | 12 |
13 | ) 14 | } 15 | 16 | Bar.propTypes = { 17 | name: PropTypes.string.isRequired, 18 | } 19 | 20 | export default Bar 21 | -------------------------------------------------------------------------------- /src/components/Results/Result/Bar/bar.style.scss: -------------------------------------------------------------------------------- 1 | @import '~styles/colors'; 2 | 3 | .bar { 4 | align-items: center; 5 | background-color: transparentize($white, .05); 6 | display: flex; 7 | padding: 5px; 8 | width: 100%; 9 | 10 | .more { 11 | background-color: transparent; 12 | box-shadow: none; 13 | display: flex; 14 | justify-content: center; 15 | margin-left: auto; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/components/Results/Result/Bar/index.js: -------------------------------------------------------------------------------- 1 | export default from './bar.container' 2 | -------------------------------------------------------------------------------- /src/components/Results/Result/Overlay/Add/add.actions.js: -------------------------------------------------------------------------------- 1 | import { getUser } from 'redux/user' 2 | 3 | export const add = id => (dispatch, getState) => { 4 | const user = getUser(getState()) 5 | 6 | firebase.database().ref(`${user.uid}/tvshows`).push(id) 7 | } 8 | -------------------------------------------------------------------------------- /src/components/Results/Result/Overlay/Add/add.container.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux' 2 | import Component from './add' 3 | import { add } from './add.actions' 4 | 5 | const mapDispatchToProps = (dispatch, { id }) => { 6 | return { 7 | onAdd: () => dispatch(add(id)), 8 | } 9 | } 10 | 11 | export default connect(undefined, mapDispatchToProps)(Component) 12 | -------------------------------------------------------------------------------- /src/components/Results/Result/Overlay/Add/add.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react' 2 | import Button from 'muicss/lib/react/button' 3 | 4 | const Add = ({ onAdd, className }) => { 5 | return ( 6 | 7 | ) 8 | } 9 | 10 | ToHere.propTypes = { 11 | onClick: PropTypes.func.isRequired, 12 | } 13 | 14 | export default ToHere 15 | -------------------------------------------------------------------------------- /src/components/TVShow/Episodes/Episode/UnView/index.js: -------------------------------------------------------------------------------- 1 | export default from './unview.container' 2 | -------------------------------------------------------------------------------- /src/components/TVShow/Episodes/Episode/UnView/unview.actions.js: -------------------------------------------------------------------------------- 1 | import { getUser } from 'redux/user' 2 | import { removeEpisode } from 'redux/episodes' 3 | import { getId } from 'redux/router' 4 | 5 | export const remove = id => (dispatch, getState) => { 6 | const user = getUser(getState()) 7 | const showId = Number(getId(getState())) 8 | const showRef = firebase.database().ref(`${user.uid}/episodes/${showId}`) 9 | 10 | showRef.once('value', (data) => { 11 | const val = data.val() 12 | const keys = Object.keys(val) 13 | const values = Object.values(val) 14 | 15 | values.forEach((v, idx) => { 16 | if (v === id) showRef.child(keys[idx]).remove() 17 | }) 18 | }) 19 | dispatch(removeEpisode(id)) 20 | } 21 | -------------------------------------------------------------------------------- /src/components/TVShow/Episodes/Episode/UnView/unview.container.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux' 2 | import { remove } from './unview.actions' 3 | import Component from './unview' 4 | 5 | const mapDispatchToProps = (dispatch, { id }) => { 6 | return { 7 | onClick: () => dispatch(remove(id)), 8 | } 9 | } 10 | 11 | export default connect(undefined, mapDispatchToProps)(Component) 12 | -------------------------------------------------------------------------------- /src/components/TVShow/Episodes/Episode/UnView/unview.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react' 2 | import Button from 'muicss/lib/react/button' 3 | 4 | const UnView = ({ onClick }) => { 5 | return ( 6 | 7 | ) 8 | } 9 | 10 | UnView.propTypes = { 11 | onClick: PropTypes.func.isRequired, 12 | } 13 | 14 | export default UnView 15 | -------------------------------------------------------------------------------- /src/components/TVShow/Episodes/Episode/View/index.js: -------------------------------------------------------------------------------- 1 | export default from './view.container' 2 | -------------------------------------------------------------------------------- /src/components/TVShow/Episodes/Episode/View/view.actions.js: -------------------------------------------------------------------------------- 1 | import { getUser } from 'redux/user' 2 | import { addEpisode } from 'redux/episodes' 3 | import { getId } from 'redux/router' 4 | 5 | export const add = id => (dispatch, getState) => { 6 | const user = getUser(getState()) 7 | const showId = Number(getId(getState())) 8 | 9 | firebase.database().ref(`${user.uid}/episodes/${showId}`).push(id) 10 | dispatch(addEpisode(id)) 11 | } 12 | -------------------------------------------------------------------------------- /src/components/TVShow/Episodes/Episode/View/view.container.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux' 2 | import { add } from './view.actions' 3 | import Component from './view' 4 | 5 | const mapDispatchToProps = (dispatch, { id }) => { 6 | return { 7 | onClick: () => dispatch(add(id)), 8 | } 9 | } 10 | 11 | export default connect(undefined, mapDispatchToProps)(Component) 12 | -------------------------------------------------------------------------------- /src/components/TVShow/Episodes/Episode/View/view.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react' 2 | import Button from 'muicss/lib/react/button' 3 | 4 | const View = ({ onClick }) => { 5 | return ( 6 | 7 | ) 8 | } 9 | 10 | View.propTypes = { 11 | onClick: PropTypes.func.isRequired, 12 | } 13 | 14 | export default View 15 | -------------------------------------------------------------------------------- /src/components/TVShow/Episodes/Episode/episode.container.js: -------------------------------------------------------------------------------- 1 | import pick from 'lodash/pick' 2 | import { connect } from 'react-redux' 3 | import { getEpisodes } from 'redux/episodes' 4 | import { getEpisode } from './episode.selectors' 5 | import Component from './episode' 6 | 7 | const mapStateToProps = (state, { id }) => { 8 | const seen = getEpisodes(state).includes(id) 9 | 10 | return { 11 | ...pick(getEpisode(state, id), ['id', 'season', 'number', 'airdate', 'airtime', 'name']), 12 | seen, 13 | } 14 | } 15 | 16 | export default connect(mapStateToProps)(Component) 17 | -------------------------------------------------------------------------------- /src/components/TVShow/Episodes/Episode/episode.jsx: -------------------------------------------------------------------------------- 1 | import padStart from 'lodash/padStart' 2 | import React, { PropTypes } from 'react' 3 | import ToHere from './ToHere' 4 | import View from './View' 5 | import UnView from './UnView' 6 | import styles from './episode.style' 7 | 8 | const Episode = ({ className, id, season, number, airdate, airtime, name, seen }) => { 9 | const classNames = [styles.episode] 10 | if (seen) classNames.push(styles.seen) 11 | if (className) classNames.push(className) 12 | 13 | return ( 14 | 15 | S{padStart(season, 2, '0')}E{padStart(number, 2, '0')} 16 | {airdate} {airtime} 17 | {name} 18 | 19 | {seen || } 20 | {seen || } 21 | {seen && } 22 | 23 | 24 | ) 25 | } 26 | 27 | Episode.propTypes = { 28 | className: PropTypes.string, 29 | id: PropTypes.number.isRequired, 30 | season: PropTypes.number.isRequired, 31 | number: PropTypes.number.isRequired, 32 | airdate: PropTypes.string.isRequired, 33 | airtime: PropTypes.string.isRequired, 34 | name: PropTypes.string.isRequired, 35 | seen: PropTypes.bool.isRequired, 36 | } 37 | 38 | export default Episode 39 | -------------------------------------------------------------------------------- /src/components/TVShow/Episodes/Episode/episode.selectors.js: -------------------------------------------------------------------------------- 1 | import { getEpisodes } from '../episodes.selectors' 2 | 3 | export const getEpisode = (state, id) => { 4 | const episodes = getEpisodes(state) 5 | 6 | return episodes.find(e => e.id === id) 7 | } 8 | -------------------------------------------------------------------------------- /src/components/TVShow/Episodes/Episode/episode.style.scss: -------------------------------------------------------------------------------- 1 | @import '~styles/colors'; 2 | 3 | .episode { 4 | height: 69px; 5 | 6 | &:hover { 7 | background-color: lighten($mui-primary-color-light, 5%); 8 | } 9 | 10 | .button { 11 | text-align: right; 12 | } 13 | 14 | &.seen { 15 | background-color: $episode-seen-bg; 16 | color: $episode-seen-fg; 17 | font-style: italic; 18 | } 19 | 20 | &.hide { 21 | height: 0; 22 | overflow: hidden; 23 | transition: 1s; 24 | 25 | td { 26 | border: 0 !important; 27 | font-size: 0; 28 | height: 0; 29 | margin: 0; 30 | overflow: hidden; 31 | padding: 0; 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/components/TVShow/Episodes/Episode/index.js: -------------------------------------------------------------------------------- 1 | export default from './episode.container' 2 | -------------------------------------------------------------------------------- /src/components/TVShow/Episodes/episodes.container.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux' 2 | import { defaultArray } from 'redux/defaults' 3 | import { fetchEpisodes } from 'redux/tvshows/episodes' 4 | import { getEpisodes } from './episodes.selectors' 5 | import Component from './episodes' 6 | 7 | const mapStateToProps = (state) => { 8 | const raw = getEpisodes(state) 9 | const episodes = (raw || defaultArray).map(e => e.id) 10 | 11 | return { 12 | loaded: raw !== undefined, 13 | episodes, 14 | } 15 | } 16 | 17 | const mapDispatchToProps = (dispatch, { id }) => { 18 | return { 19 | load: () => dispatch(fetchEpisodes(id)), 20 | } 21 | } 22 | 23 | export default connect(mapStateToProps, mapDispatchToProps)(Component) 24 | -------------------------------------------------------------------------------- /src/components/TVShow/Episodes/episodes.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react' 2 | import loader from 'hoc-react-loader' 3 | import Episode from './Episode' 4 | import styles from './episodes.style.scss' 5 | 6 | const Episodes = ({ className, episodes }) => { 7 | return ( 8 | 9 | 10 | 11 | 12 | 13 | 14 | 16 | 17 | 18 | {episodes.map(id => )} 19 | 20 |
NuméroDateTitre 15 |
21 | ) 22 | } 23 | 24 | Episodes.propTypes = { 25 | episodes: PropTypes.arrayOf(PropTypes.number).isRequired, 26 | className: PropTypes.string, 27 | } 28 | 29 | export default loader(Episodes) 30 | -------------------------------------------------------------------------------- /src/components/TVShow/Episodes/episodes.selectors.js: -------------------------------------------------------------------------------- 1 | import sortBy from 'lodash/sortBy' 2 | import { createSelector } from 'reselect' 3 | import { getId } from '../../../redux/router' 4 | import { helpers, getTVShows } from '../../../redux/tvshows' 5 | import { defaultArray } from '../../../redux/defaults' 6 | 7 | export const getEpisodes = createSelector( 8 | [getId, getTVShows], 9 | (id, tvshows) => { 10 | const tvshow = helpers.getById({ tvshows }, Number(id)) 11 | const episodes = tvshow.episodes || defaultArray 12 | 13 | return sortBy(episodes, ['season', 'number']).reverse() 14 | } 15 | ) 16 | 17 | /* export const getEpisodes = (state) => { 18 | const id = Number(getId(state)) 19 | const tvshow = helpers.getById(state, Number(id)) 20 | const episodes = tvshow.episodes || defaultArray 21 | 22 | return sortBy(episodes, ['season', 'number']).reverse() 23 | } */ 24 | -------------------------------------------------------------------------------- /src/components/TVShow/Episodes/episodes.selectors.spec.js: -------------------------------------------------------------------------------- 1 | /* eslint-env mocha */ 2 | /* eslint-disable no-unused-expressions */ 3 | 4 | import _ from 'lodash' 5 | import { getEpisodes } from './episodes.selectors' 6 | 7 | describe('episodes selectors', () => { 8 | describe('Natures', () => { 9 | it('should be a function', () => { 10 | _.isFunction(getEpisodes).should.be.true 11 | }) 12 | 13 | it('should return an object', () => { 14 | const raw = { 15 | router: { params: { id: '12' } }, 16 | tvshows: [ 17 | { id: 12, episodes: [{ id: 10, title: 'title10' }] }, 18 | ], 19 | } 20 | 21 | const episodes = getEpisodes(raw) 22 | 23 | _.isObject(episodes).should.be.true 24 | _.isFunction(episodes).should.be.false 25 | }) 26 | }) 27 | 28 | describe('getEpisodes', () => { 29 | it('should return episodes from the right tvshow', () => { 30 | const raw = { 31 | router: { params: { id: '12' } }, 32 | tvshows: [ 33 | { id: 2, episodes: [{ id: 1, title: 'title1' }] }, 34 | { id: 12, episodes: [{ id: 10, title: 'title10' }] }, 35 | ], 36 | } 37 | 38 | const episodes = getEpisodes(raw) 39 | episodes.should.be.deep.equals([{ id: 10, title: 'title10' }]) 40 | }) 41 | 42 | it('should return episodes ordered by season then by number (desc)', () => { 43 | const raw = { 44 | router: { params: { id: '89' } }, 45 | tvshows: [ 46 | { 47 | id: 89, 48 | episodes: [ 49 | { id: 10, title: 'title10 S02E08', season: 2, number: 8 }, 50 | { id: 11, title: 'title11 S03E04', season: 3, number: 4 }, 51 | { id: 12, title: 'title12 S03E03', season: 3, number: 3 }, 52 | { id: 14, title: 'title14 S02E10', season: 2, number: 10 }, 53 | { id: 13, title: 'title13 S02E11', season: 2, number: 11 }, 54 | ], 55 | }, 56 | ], 57 | } 58 | 59 | const episodes = getEpisodes(raw) 60 | episodes.should.be.deep.equals([ 61 | { id: 11, title: 'title11 S03E04', season: 3, number: 4 }, 62 | { id: 12, title: 'title12 S03E03', season: 3, number: 3 }, 63 | { id: 13, title: 'title13 S02E11', season: 2, number: 11 }, 64 | { id: 14, title: 'title14 S02E10', season: 2, number: 10 }, 65 | { id: 10, title: 'title10 S02E08', season: 2, number: 8 }, 66 | ]) 67 | }) 68 | }) 69 | }) 70 | 71 | /* eslint-enable no-unused-expressions */ 72 | -------------------------------------------------------------------------------- /src/components/TVShow/Episodes/episodes.style.scss: -------------------------------------------------------------------------------- 1 | @import '~styles/colors'; 2 | 3 | .table { 4 | tr { 5 | height: 50px; 6 | 7 | td { 8 | line-height: 1.429 !important; 9 | padding: 0 10px !important; 10 | } 11 | } 12 | 13 | thead { 14 | background-color: $background-color; 15 | 16 | tr { 17 | border-bottom: 3px solid darken($background-color, 15%); 18 | height: 50px; 19 | 20 | th { 21 | border-bottom: 0; 22 | font-size: 18px; 23 | font-weight: 700; 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/components/TVShow/Episodes/index.js: -------------------------------------------------------------------------------- 1 | export default from './episodes.container' 2 | -------------------------------------------------------------------------------- /src/components/TVShow/index.js: -------------------------------------------------------------------------------- 1 | export default from './tvshow.container' 2 | -------------------------------------------------------------------------------- /src/components/TVShow/tvshow.actions.js: -------------------------------------------------------------------------------- 1 | import { getId } from 'redux/router' 2 | import { fetchTvShow } from 'redux/tvshows' 3 | import { addEpisodes } from 'redux/episodes' 4 | 5 | /* global firebase */ 6 | export const connectFirebase = () => (dispatch, getState) => { 7 | firebase.auth().onAuthStateChanged((user) => { 8 | if (user) { 9 | const showId = Number(getId(getState())) 10 | const ref = firebase.database().ref(`${user.uid}/episodes/${showId}`) 11 | 12 | ref.once('value', (data) => { 13 | const episodes = Object.values(data.val()) 14 | dispatch(addEpisodes(episodes)) 15 | }) 16 | } 17 | }) 18 | } 19 | 20 | export const load = () => (dispatch, getState) => { 21 | const id = Number(getId(getState())) 22 | dispatch(fetchTvShow(id)) 23 | dispatch(connectFirebase()) 24 | } 25 | -------------------------------------------------------------------------------- /src/components/TVShow/tvshow.container.js: -------------------------------------------------------------------------------- 1 | import { connect } from 'react-redux' 2 | import { getId } from 'redux/router' 3 | import { load } from './tvshow.actions' 4 | import { getTvShow } from './tvshow.selectors' 5 | import Component from './tvshow' 6 | 7 | /* 8 | const mapStateToProps = (state) => { 9 | const id = Number(getId(state)) 10 | const tvshow = helpers.getById(state, id) 11 | 12 | return { 13 | loaded: id === tvshow.id, 14 | ...pick(tvshow, ['id', 'name', 'image', 'summary']), 15 | } 16 | } 17 | */ 18 | 19 | const mapStateToProps = (state) => { 20 | const id = Number(getId(state)) 21 | const tvshow = getTvShow(state) 22 | return { 23 | loaded: id === tvshow.id, 24 | ...tvshow, 25 | } 26 | } 27 | 28 | const mapDispatchToProps = (dispatch) => { 29 | return { 30 | load: () => dispatch(load()), 31 | } 32 | } 33 | 34 | export default connect(mapStateToProps, mapDispatchToProps)(Component) 35 | -------------------------------------------------------------------------------- /src/components/TVShow/tvshow.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react' 2 | import loader from 'hoc-react-loader' 3 | import Episodes from './Episodes' 4 | import styles from './tvshow.style' 5 | 6 | /* eslint-disable react/no-danger */ 7 | 8 | const TVShow = ({ id, name, image, summary }) => { 9 | return ( 10 |
11 |

{name}

12 |
13 |

En résumé

14 |
15 | 16 |
17 |
18 |
19 | 20 |
21 |

Episodes

22 |
23 | 24 |
25 |
26 |
27 | ) 28 | } 29 | 30 | /* eslint-enable react/no-danger */ 31 | 32 | TVShow.propTypes = { 33 | name: PropTypes.string.isRequired, 34 | image: PropTypes.string.isRequired, 35 | id: PropTypes.number.isRequired, 36 | summary: PropTypes.string, 37 | } 38 | 39 | export default loader(TVShow) 40 | -------------------------------------------------------------------------------- /src/components/TVShow/tvshow.selectors.js: -------------------------------------------------------------------------------- 1 | import pick from 'lodash/pick' 2 | import { createSelector } from 'reselect' 3 | import { getId } from 'redux/router' 4 | import { helpers, getTVShows } from 'redux/tvshows' 5 | 6 | const getRawTvShow = createSelector( 7 | [getTVShows, getId], 8 | (tvshows, id) => { 9 | return helpers.getById({ tvshows }, Number(id)) 10 | } 11 | ) 12 | 13 | export const getTvShow = createSelector( 14 | [getRawTvShow], 15 | (rawTvShow) => { 16 | return pick(rawTvShow, ['id', 'name', 'image', 'summary']) 17 | } 18 | ) 19 | -------------------------------------------------------------------------------- /src/components/TVShow/tvshow.style.scss: -------------------------------------------------------------------------------- 1 | @import '~styles/colors'; 2 | @import '~styles/box-shadow'; 3 | 4 | .tvshow { 5 | margin: 0 auto; 6 | width: 90%; 7 | 8 | h1 { 9 | text-align: center; 10 | } 11 | 12 | .abstract { 13 | display: flex; 14 | margin: 0 10px; 15 | 16 | img { 17 | margin: 10px; 18 | } 19 | 20 | .summary { 21 | margin: 10px; 22 | } 23 | } 24 | } 25 | 26 | .card { 27 | background-color: $white; 28 | border-radius: 2px; 29 | box-shadow: $card-bs; 30 | margin-bottom: 20px; 31 | padding-bottom: 10px; 32 | 33 | h2 { 34 | background-color: $mui-primary-color; 35 | color: $white; 36 | font-family: 'Roboto Condensed', 'Arial'; 37 | font-size: 26px; 38 | font-weight: 700; 39 | height: 50px; 40 | line-height: 50px; 41 | margin-bottom: 10px; 42 | margin-top: 0; 43 | padding-left: 20px; 44 | } 45 | 46 | .episodes { 47 | padding: 0 20px; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/global.scss: -------------------------------------------------------------------------------- 1 | // This file is not imported in the standard way, you can look at webpack.config.js 2 | // to see how it is done. 3 | // The reason is to avoid css-loader to modularize mui classes 4 | 5 | // https://www.muicss.com/docs/v1/css-js/customization 6 | // MUI imports normalize 3.0, so we don't need to import one 7 | @import './styles/colors'; 8 | 9 | // import MUI SASS 10 | @import '~muicss/lib/sass/mui'; 11 | 12 | // Global config 13 | * { 14 | font-family: 'Roboto', 'Arial'; 15 | } 16 | 17 | h1 { 18 | font-family: 'Roboto Condensed', 'Arial'; 19 | font-size: 40px; 20 | font-weight: 700; 21 | margin-bottom: 30px; 22 | } 23 | 24 | body { 25 | background-color: $background-color; 26 | } 27 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | TV Scrub 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | -------------------------------------------------------------------------------- /src/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { render } from 'react-dom' 3 | import { RouterProvider } from 'redux-little-router' 4 | import { Provider } from 'react-redux' 5 | 6 | import 'isomorphic-fetch' 7 | /* eslint import/no-extraneous-dependencies: 0 import/no-unresolved: 0 */ 8 | import 'file?name=[name].[ext]!./index.html' 9 | import './global.scss' 10 | 11 | import App from './components/App' 12 | import store from './redux/store' 13 | 14 | /* eslint-env browser */ 15 | 16 | render( 17 | 18 | 19 | 20 | 21 | 22 | , document.getElementById('app') 23 | ) 24 | -------------------------------------------------------------------------------- /src/redux/constants.js: -------------------------------------------------------------------------------- 1 | // export const API_URL = 'https://tvmaze-https-tcwlcawmcs.now.sh/' 2 | // export const API_URL = 'http://api.tvmaze.com/' 3 | export const API_URL = 'https://tvmaze-https.chocakai.org/' 4 | -------------------------------------------------------------------------------- /src/redux/defaults.js: -------------------------------------------------------------------------------- 1 | export const defaultObject = {} 2 | export const defaultArray = [] 3 | 4 | export const defaultShow = { image: defaultObject } 5 | export const defaultImage = { medium: 'http://tvmazecdn.com/images/no-img/no-img-portrait-text.png' } 6 | -------------------------------------------------------------------------------- /src/redux/episodes/episodes.actions.js: -------------------------------------------------------------------------------- 1 | export const ADD_EPISODE = 'ADD_EPISODE' 2 | export const addEpisode = (id) => { 3 | return { 4 | type: ADD_EPISODE, 5 | payload: id, 6 | } 7 | } 8 | 9 | export const ADD_EPISODES = 'ADD_EPISODES' 10 | export const addEpisodes = (episodes) => { 11 | return { 12 | type: ADD_EPISODES, 13 | payload: episodes, 14 | } 15 | } 16 | 17 | export const REMOVE_EPISODE = 'REMOVE_EPISODE' 18 | export const removeEpisode = (id) => { 19 | return { 20 | type: REMOVE_EPISODE, 21 | payload: id, 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/redux/episodes/episodes.js: -------------------------------------------------------------------------------- 1 | import uniq from 'lodash/uniq' 2 | import { ADD_EPISODE, ADD_EPISODES, REMOVE_EPISODE } from './episodes.actions' 3 | 4 | export const initState = [] 5 | export const initAction = { type: 'UNKOWN' } 6 | 7 | export default (state = initState, action = initAction) => { 8 | switch (action.type) { 9 | case REMOVE_EPISODE: return [...state].filter(id => id !== action.payload) 10 | case ADD_EPISODE: return uniq([...state, action.payload]) 11 | case ADD_EPISODES: return uniq([...state, ...action.payload]) 12 | default: return state 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/redux/episodes/episodes.selectors.js: -------------------------------------------------------------------------------- 1 | export const getEpisodes = state => state.episodes 2 | -------------------------------------------------------------------------------- /src/redux/episodes/index.js: -------------------------------------------------------------------------------- 1 | export default from './episodes' 2 | export * from './episodes.actions' 3 | export * from './episodes.selectors' 4 | -------------------------------------------------------------------------------- /src/redux/results/index.js: -------------------------------------------------------------------------------- 1 | export default from './results' 2 | export * from './results.actions' 3 | export * from './results.selectors' 4 | -------------------------------------------------------------------------------- /src/redux/results/results.actions.js: -------------------------------------------------------------------------------- 1 | export const SET_RESULTS = 'SET_RESULTS' 2 | export const setResults = (results) => { 3 | return { 4 | type: SET_RESULTS, 5 | payload: results, 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/redux/results/results.js: -------------------------------------------------------------------------------- 1 | import { SET_RESULTS } from './results.actions' 2 | 3 | export const initState = [] 4 | export const initAction = {} 5 | 6 | export default (state = initState, action = initAction) => { 7 | switch (action.type) { 8 | case SET_RESULTS: return action.payload 9 | default: return state 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/redux/results/results.selectors.js: -------------------------------------------------------------------------------- 1 | export const getResults = state => state.results 2 | -------------------------------------------------------------------------------- /src/redux/router/index.js: -------------------------------------------------------------------------------- 1 | export * from './router.selectors' 2 | export default from './router' 3 | -------------------------------------------------------------------------------- /src/redux/router/router.js: -------------------------------------------------------------------------------- 1 | export default { 2 | '/': { 3 | title: 'HOME', 4 | '/toto': { 5 | title: 'TOTO', 6 | }, 7 | '/tvshow/:id': { 8 | title: 'TVSHOW', 9 | }, 10 | }, 11 | } 12 | -------------------------------------------------------------------------------- /src/redux/router/router.selectors.js: -------------------------------------------------------------------------------- 1 | export const getRouter = state => state.router 2 | export const getParams = state => getRouter(state).params 3 | export const getId = state => getParams(state).id 4 | export const getTitle = state => getRouter(state).result.title 5 | -------------------------------------------------------------------------------- /src/redux/search/index.js: -------------------------------------------------------------------------------- 1 | export * from './search.actions' 2 | export * from './search.selectors' 3 | export default from './search' 4 | -------------------------------------------------------------------------------- /src/redux/search/search.actions.js: -------------------------------------------------------------------------------- 1 | export const SET_TEXT = 'SET_TEXT' 2 | export const setText = (text) => { 3 | return { 4 | type: SET_TEXT, 5 | payload: text, 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/redux/search/search.actions.spec.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import { setText, SET_TEXT } from './search.actions' 3 | 4 | /* eslint-env mocha */ 5 | /* eslint-disable no-unused-expressions */ 6 | 7 | describe('search actions', () => { 8 | describe(`${SET_TEXT} action`, () => { 9 | describe('Natures', () => { 10 | describe('setText function', () => { 11 | it('should be a function', () => { 12 | _.isFunction(setText).should.be.true 13 | }) 14 | 15 | it('should return a new object', () => { 16 | const first = setText('text') 17 | const second = setText('text2') 18 | 19 | _.isObject(first).should.be.true 20 | _.isFunction(first).should.be.false 21 | _.isObject(second).should.be.true 22 | _.isFunction(second).should.be.false 23 | 24 | first.should.not.be.equals(second) 25 | }) 26 | }) 27 | 28 | describe('SET_TEXT constant', () => { 29 | it('should be a string', () => { 30 | _.isString(SET_TEXT).should.be.true 31 | }) 32 | }) 33 | }) 34 | 35 | describe('returned values', () => { 36 | it('should return the right `type`', () => { 37 | const action = setText('text') 38 | action.type.should.be.equals(SET_TEXT) 39 | }) 40 | 41 | it('should return the right `payload`', () => { 42 | const action = setText('text') 43 | action.payload.should.be.equals('text') 44 | }) 45 | }) 46 | }) 47 | }) 48 | 49 | /* eslint-enable no-unused-expressions */ 50 | -------------------------------------------------------------------------------- /src/redux/search/search.js: -------------------------------------------------------------------------------- 1 | import { SET_TEXT } from './search.actions' 2 | 3 | export const initState = { text: '' } 4 | export const initAction = { type: 'UNKNOWN' } 5 | 6 | export default (state = initState, action = initAction) => { 7 | switch (action.type) { 8 | case SET_TEXT: return { ...state, text: action.payload } 9 | default: return state 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/redux/search/search.selectors.js: -------------------------------------------------------------------------------- 1 | export const getSearch = state => state.search 2 | export const getText = state => getSearch(state).text 3 | -------------------------------------------------------------------------------- /src/redux/search/search.spec.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import reducer, { initState, initAction } from './search' 3 | import { 4 | SET_TEXT, setText, 5 | } from './search.actions' 6 | 7 | /* eslint-env mocha */ 8 | /* eslint-disable no-unused-expressions */ 9 | 10 | describe('search reducer', () => { 11 | describe('Natures', () => { 12 | describe('reducer', () => { 13 | it('should be a function', () => { 14 | _.isFunction(reducer).should.be.true 15 | }) 16 | 17 | it('should return an object', () => { 18 | const state = reducer({}, { type: 'UNKNOWN' }) 19 | _.isObject(state).should.be.true 20 | _.isFunction(state).should.be.false 21 | }) 22 | }) 23 | 24 | describe('initState', () => { 25 | it('should be an object', () => { 26 | _.isObject(initState).should.be.true 27 | _.isFunction(initState).should.be.false 28 | }) 29 | }) 30 | 31 | describe('initAction', () => { 32 | it('should be an object', () => { 33 | _.isObject(initAction).should.be.true 34 | _.isFunction(initAction).should.be.false 35 | }) 36 | 37 | it('should be UNKOWN', () => { 38 | initAction.type.should.be.deep.equals('UNKNOWN') 39 | }) 40 | }) 41 | }) 42 | 43 | it('should initialize', () => { 44 | const state = reducer() 45 | state.should.be.deep.equals(initState) 46 | state.should.be.deep.equals({ 47 | text: '', 48 | }) 49 | }) 50 | 51 | describe(`${SET_TEXT} action`, () => { 52 | it('should set the text field', () => { 53 | let state = reducer(initState, setText(20)) 54 | state.should.be.deep.equals({ 55 | ...initState, 56 | text: 20, 57 | }) 58 | 59 | state = reducer(state, setText('scrubs')) 60 | state.should.be.deep.equals({ 61 | ...initState, 62 | text: 'scrubs', 63 | }) 64 | }) 65 | 66 | it('should not override other fields', () => { 67 | const state = reducer({ other: 'field' }, setText('text')) 68 | state.should.be.deep.equals({ 69 | other: 'field', 70 | text: 'text', 71 | }) 72 | }) 73 | 74 | it('should not mutate the state', () => { 75 | const inputState = { other: 'field' } 76 | const savedState = { ...inputState } 77 | const state = reducer(inputState, setText('text')) 78 | 79 | state.should.not.be.deep.equals(inputState) 80 | inputState.should.be.deep.equals(savedState) 81 | }) 82 | }) 83 | }) 84 | 85 | /* eslint-enable no-unused-expressions */ 86 | -------------------------------------------------------------------------------- /src/redux/seen/index.js: -------------------------------------------------------------------------------- 1 | export default from './seen' 2 | export * from './seen.actions' 3 | export * from './seen.selectors' 4 | -------------------------------------------------------------------------------- /src/redux/seen/seen.actions.js: -------------------------------------------------------------------------------- 1 | export const ADD_SEEN = 'ADD_SEEN' 2 | export const addSeen = (id) => { 3 | return { 4 | type: ADD_SEEN, 5 | payload: id, 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/redux/seen/seen.js: -------------------------------------------------------------------------------- 1 | import uniq from 'lodash/uniq' 2 | import { ADD_SEEN } from './seen.actions' 3 | 4 | export const initState = [] 5 | export const initAction = { type: 'UNKOWN' } 6 | 7 | export default (state = initState, action = initAction) => { 8 | switch (action.type) { 9 | case ADD_SEEN: return uniq([...state, action.payload]) 10 | default: return state 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/redux/seen/seen.selectors.js: -------------------------------------------------------------------------------- 1 | export const getSeen = state => state.seen 2 | -------------------------------------------------------------------------------- /src/redux/store.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware, combineReducers, compose } from 'redux' 2 | import { createStoreWithRouter, initializeCurrentLocation } from 'redux-little-router' 3 | import thunkMiddleware from 'redux-thunk' 4 | import router from './router' 5 | import search from './search' 6 | import user from './user' 7 | import seen from './seen' 8 | import tvshows from './tvshows' 9 | import results from './results' 10 | import episodes from './episodes' 11 | 12 | const store = createStore( 13 | combineReducers({ 14 | search, 15 | user, 16 | seen, 17 | tvshows, 18 | results, 19 | episodes, 20 | }), 21 | compose( 22 | applyMiddleware(thunkMiddleware), 23 | createStoreWithRouter({ 24 | routes: router, 25 | pathname: window.location.pathname, 26 | }), 27 | /* eslint-env browser */ 28 | window.devToolsExtension ? window.devToolsExtension() : f => f 29 | ) 30 | ) 31 | 32 | const initialLocation = store.getState().router 33 | if (initialLocation) { 34 | store.dispatch(initializeCurrentLocation(initialLocation)) 35 | } 36 | 37 | export default store 38 | -------------------------------------------------------------------------------- /src/redux/tvshows/episodes/episodes.actions.js: -------------------------------------------------------------------------------- 1 | import { API_URL } from '../../constants' 2 | 3 | export const SET_EPISODES = 'SET_EPISODES' 4 | export const setEpisodes = (id, episodes) => { 5 | return { 6 | type: SET_EPISODES, 7 | payload: { 8 | id, 9 | episodes, 10 | }, 11 | } 12 | } 13 | 14 | export const fetchEpisodes = id => (dispatch) => { 15 | fetch(`${API_URL}shows/${id}/episodes`) 16 | .then(raw => raw.json()) 17 | .then(episodes => dispatch(setEpisodes(id, episodes))) 18 | } 19 | -------------------------------------------------------------------------------- /src/redux/tvshows/episodes/episodes.js: -------------------------------------------------------------------------------- 1 | import { SET_EPISODES } from './episodes.actions' 2 | 3 | export const initState = [] 4 | export const initAction = { type: 'UNKNOWN' } 5 | 6 | export default (state = initState, action = initAction) => { 7 | switch (action.type) { 8 | case SET_EPISODES: return action.payload.episodes 9 | default: return state 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/redux/tvshows/episodes/index.js: -------------------------------------------------------------------------------- 1 | export default from './episodes' 2 | export * from './episodes.actions' 3 | -------------------------------------------------------------------------------- /src/redux/tvshows/index.js: -------------------------------------------------------------------------------- 1 | export default from './tvshows' 2 | export * from './tvshows.actions' 3 | export * from './tvshows.selectors' 4 | -------------------------------------------------------------------------------- /src/redux/tvshows/tvshows.actions.js: -------------------------------------------------------------------------------- 1 | import { API_URL } from '../constants' 2 | 3 | export const ADD_TVSHOW = 'ADD_TVSHOW' 4 | export const addTVShow = (tvshow) => { 5 | return { 6 | type: ADD_TVSHOW, 7 | payload: tvshow, 8 | } 9 | } 10 | 11 | export const fetchTvShow = id => (dispatch) => { 12 | fetch(`${API_URL}shows/${id}`) 13 | .then(raw => raw.json()) 14 | .then(tvshow => dispatch(addTVShow(tvshow))) 15 | } 16 | -------------------------------------------------------------------------------- /src/redux/tvshows/tvshows.js: -------------------------------------------------------------------------------- 1 | import findIndex from 'lodash/findIndex' 2 | import { ADD_TVSHOW } from './tvshows.actions' 3 | import episodes, { SET_EPISODES } from './episodes' 4 | 5 | export const initState = [] 6 | export const initAction = { type: 'UNKNOWN' } 7 | 8 | export default (state = initState, action = initAction) => { 9 | switch (action.type) { 10 | case SET_EPISODES: { 11 | const idx = findIndex(state, ['id', action.payload.id]) 12 | if (idx === -1) return state 13 | 14 | const newState = [...state] 15 | newState[idx] = { ...newState[idx], episodes: episodes(newState[idx.episodes], action) } 16 | 17 | return newState 18 | } 19 | case ADD_TVSHOW: return [...state, action.payload] 20 | default: return state 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/redux/tvshows/tvshows.selectors.js: -------------------------------------------------------------------------------- 1 | import { defaultShow, defaultImage } from '../defaults' 2 | 3 | export const getTVShows = state => state.tvshows 4 | export const helpers = { 5 | getImage: (tvshow = defaultShow) => (tvshow.image || defaultImage).medium, 6 | enhanceTVShow: (tvshow = defaultShow) => { 7 | return { ...tvshow, image: helpers.getImage(tvshow) } 8 | }, 9 | getById: (state, id) => helpers.enhanceTVShow(getTVShows(state).find(tvshow => tvshow.id === id)), 10 | } 11 | -------------------------------------------------------------------------------- /src/redux/user/index.js: -------------------------------------------------------------------------------- 1 | export * from './user.selectors' 2 | export * from './user.actions' 3 | export default from './user' 4 | -------------------------------------------------------------------------------- /src/redux/user/user.actions.js: -------------------------------------------------------------------------------- 1 | export const SET_USER = 'SET_USER' 2 | export const setUser = (user) => { 3 | return { 4 | type: SET_USER, 5 | payload: user, 6 | } 7 | } 8 | 9 | export const RESET_USER = 'RESET_USER' 10 | export const resetUser = () => { 11 | return { 12 | type: RESET_USER, 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/redux/user/user.js: -------------------------------------------------------------------------------- 1 | import { SET_USER, RESET_USER } from './user.actions' 2 | 3 | export const initState = { displayName: '', photoURL: '' } 4 | export const initAction = { type: 'UNKOWN' } 5 | 6 | export default (state = initState, action = initAction) => { 7 | switch (action.type) { 8 | case SET_USER: return action.payload 9 | case RESET_USER: return initState 10 | default: return state 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/redux/user/user.selectors.js: -------------------------------------------------------------------------------- 1 | export const getUser = state => state.user 2 | export const isConnected = state => getUser(state).uid !== undefined 3 | -------------------------------------------------------------------------------- /src/styles/_box-shadow.scss: -------------------------------------------------------------------------------- 1 | $z2: 0 3px 6px rgba(0, 0 , 0, .16), 0 3px 6px rgba(0, 0, 0, .23); 2 | $card-bs: 4px 4px 8px rgba(150, 150, 150, .2); 3 | -------------------------------------------------------------------------------- /src/styles/_colors.scss: -------------------------------------------------------------------------------- 1 | @import '~muicss/lib/sass/mui/colors'; 2 | 3 | // customize MUI variables 4 | $mui-primary-color: mui-color('red', '500'); 5 | $mui-primary-color-dark: mui-color('red', '700'); 6 | $mui-primary-color-light: mui-color('red', '100'); 7 | 8 | $mui-accent-color: mui-color('green', 'A200'); 9 | $mui-accent-color-dark: mui-color('green', 'A100'); 10 | $mui-accent-color-light: mui-color('green', 'A400'); 11 | 12 | $white: #fff; 13 | $black: #000; 14 | 15 | $background-light: rgba(255, 255, 255, .16); 16 | $episode-seen-bg: #fafafa; 17 | $episode-seen-fg: #9e9e9e; 18 | 19 | $background-color: #f2f2f2; 20 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | /* eslint-disable no-console */ 3 | const path = require('path') 4 | const webpack = require('webpack') 5 | const ExtractTextPlugin = require('extract-text-webpack-plugin') 6 | const autoprefixer = require('autoprefixer') 7 | const HtmlWebpackPlugin = require('html-webpack-plugin') 8 | 9 | const dev = (process.env.NODE_ENV !== 'production') 10 | 11 | console.log(`MODE=${dev ? 'dev' : 'production'}`) 12 | 13 | function getEntrySources(sources) { 14 | if (dev) { 15 | sources.push('webpack/hot/only-dev-server') 16 | } 17 | 18 | return sources 19 | } 20 | 21 | function getLoaders(loaders) { 22 | if (dev) loaders.push('react-hot') 23 | loaders.push('babel') 24 | 25 | return loaders 26 | } 27 | 28 | function getPlugins(plugins) { 29 | plugins.push(new HtmlWebpackPlugin({ 30 | template: 'src/index.html', 31 | inject: true, 32 | hash: true, 33 | })) 34 | 35 | if (dev) plugins.push(new webpack.HotModuleReplacementPlugin()) 36 | else plugins.push(new ExtractTextPlugin('[name].css')) 37 | 38 | return plugins 39 | } 40 | 41 | function getRawCssLoaders(module, inject) { 42 | const loaders = [] 43 | if (inject) loaders.push('style') 44 | loaders.push(`css${module ? '?modules&localIdentName=[path]_[local]__[hash:base64:5]' : ''}`) 45 | if (!dev) loaders.push('postcss') 46 | loaders.push('sass') 47 | 48 | return loaders 49 | } 50 | 51 | module.exports = { 52 | devtool: dev ? 'eval' : '', 53 | entry: { 54 | tvscrub: getEntrySources([ 55 | './src', 56 | ]), 57 | }, 58 | output: { 59 | path: path.join(__dirname, 'public'), 60 | filename: '[name].js', 61 | publicPath: '/', 62 | }, 63 | resolve: { 64 | root: [path.resolve('./src'), path.resolve('./src/components')], 65 | extensions: ['', '.js', '.jsx', '.scss'], 66 | }, 67 | plugins: getPlugins([]), 68 | module: { 69 | loaders: [{ 70 | test: /\.jsx?$/, 71 | loaders: getLoaders([]), 72 | exclude: /node_modules/, 73 | }, { 74 | test: /global\.scss/, 75 | loaders: dev ? getRawCssLoaders(false, true) : [], 76 | loader: dev ? '' : ExtractTextPlugin.extract(getRawCssLoaders(false)), 77 | }, { 78 | test: /\.s?css$/, 79 | exclude: [/node_modules/, /global\.scss/], 80 | loaders: dev ? getRawCssLoaders(true, true) : [], 81 | loader: dev ? '' : ExtractTextPlugin.extract(getRawCssLoaders(true)), 82 | }], 83 | }, 84 | postcss: () => [autoprefixer], 85 | } 86 | --------------------------------------------------------------------------------