├── .gitignore ├── README.md ├── docs └── demo │ ├── demo2.gif │ └── demo3.gif ├── package.json ├── public ├── favicon.ico ├── index.html ├── manifest.json ├── service-worker.js └── sw-toolbox.js ├── src ├── assets │ └── images │ │ ├── ars-technica.png │ │ ├── sosub.png │ │ ├── techcrunch.jpg │ │ └── tnw.jpg ├── components │ ├── CardComponent │ │ ├── index.js │ │ └── styles.css │ ├── Header │ │ ├── index.js │ │ └── styles.css │ ├── NoInternet │ │ └── index.js │ └── index.js ├── containers │ ├── AppWrapper │ │ ├── index.js │ │ └── styles.css │ ├── HomePage │ │ ├── index.js │ │ └── styles.css │ ├── NewsListing │ │ ├── actions.js │ │ ├── constants.js │ │ ├── index.js │ │ ├── reducer.js │ │ ├── sagas.js │ │ └── styles.css │ └── NotFound │ │ └── index.js ├── index.js ├── logo.svg ├── reducers.js ├── routes.js ├── sagas.js └── utils │ ├── configs.js │ ├── constants.js │ ├── index.js │ └── request.js └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | 6 | # testing 7 | coverage 8 | 9 | # production 10 | build 11 | 12 | # misc 13 | .DS_Store 14 | .env 15 | *.log 16 | 17 | # editor 18 | .vscode -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # What's this project? 2 | 3 | This is a demo of PWA (Progressive Web App) based on [react-create-app](https://github.com/facebookincubator/create-react-app) and [create-react-pwa](https://github.com/jeffposnick/create-react-pwa). I made a simple demo about web app display technology news using API from [https://newsapi.org/](https://newsapi.org/). 4 | 5 | # Development 6 | 7 | This project I have included: redux, reactjs, redux-saga, react-router & material-ui. 8 | 9 | cd to directory contains ```package.json``` and run commands below: 10 | ``` 11 | npm i 12 | ``` 13 | 14 | ``` 15 | npm start 16 | ``` 17 | 18 | And then go to [http://localhost:3000/](http://localhost:3000/) 19 | 20 | ## Learn more 21 | 22 | - [redux](https://github.com/reactjs/redux) 23 | - [redux-saga](https://github.com/redux-saga/redux-saga) 24 | - [react-router](https://github.com/ReactTraining/react-router) 25 | - [material-ui](http://www.material-ui.com/#/) 26 | - [offline-js](http://github.hubspot.com/offline/docs/welcome/) 27 | 28 | ### Description 29 | 30 | - `redux & redux-saga`: To handle data flow 31 | - `react-router`: To handle routing 32 | - `material-ui`: To handle UI 33 | - `offline-js`: To detect when user is in offline mode to display snackbar & change color of UI to gray color. 34 | 35 | ## Add to home screen 36 | 37 | In this mode, you should get: 38 | - **address bar**: disappear when you use your web app 39 | - **touch icon**: you will see the icon of your web app on your home screen (on phone only) 40 | 41 | ### iOS 42 | 43 | One note that. If you wish to have `Add to home screen` mode at iOS. You have to put the meta tags defined by Apple to you ``. You can follow this url to make one: 44 | 45 | [https://developer.apple.com/library/content/documentation/AppleApplications/Reference/SafariWebContent/ConfiguringWebApplications/ConfiguringWebApplications.html](https://developer.apple.com/library/content/documentation/AppleApplications/Reference/SafariWebContent/ConfiguringWebApplications/ConfiguringWebApplications.html) 46 | 47 | ### Android 48 | 49 | Android will use the `manifest.json` to handle `Add to home screen` mode. You can follow this url make one: 50 | 51 | [https://developer.mozilla.org/en-US/docs/Web/Manifest](https://developer.mozilla.org/en-US/docs/Web/Manifest) 52 | 53 | # Production 54 | 55 | cd to directory contains ```package.json``` and run commands below: 56 | ``` 57 | npm run build 58 | ``` 59 | 60 | ``` 61 | pushstate-server build 62 | ``` 63 | 64 | And then go to [http://localhost:9000/](http://localhost:9000/) 65 | 66 | # Demo 67 | 68 | ## Demo 1 69 | 70 | [https://www.youtube.com/watch?v=U35B31dBvBk](https://www.youtube.com/watch?v=U35B31dBvBk) 71 | 72 | [![Progressive web app demo](http://i.imgur.com/wmYg8pX.png)](https://www.youtube.com/watch?v=U35B31dBvBk "Progressive web app demo") 73 | 74 | ## Demo 2 75 | Color change when web app is in offline mode 76 | 77 | ![img](docs/demo/demo2.gif) 78 | 79 | ## Demo 3 80 | Touch icon in `Add to home screen` mode 81 | 82 | ![img](docs/demo/demo3.gif) 83 | 84 | # References 85 | 86 | - [create-react-app](https://github.com/facebookincubator/create-react-app) 87 | 88 | - [create-react-pwa](https://github.com/jeffposnick/create-react-pwa) 89 | 90 | - [sw-toolbox](https://github.com/GoogleChrome/sw-toolbox) 91 | 92 | - [redux-saga](https://redux-saga.github.io/redux-saga/docs/introduction/BeginnerTutorial.html) 93 | 94 | - [https://jakearchibald.com/2014/offline-cookbook/#network-only](https://jakearchibald.com/2014/offline-cookbook/#network-only) 95 | 96 | - [https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API/Using_Service_Workers#Basic_architecture](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API/Using_Service_Workers#Basic_architecture) -------------------------------------------------------------------------------- /docs/demo/demo2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidnguyen11/create-react-progressive-web-app/cfc38305f5e66f839ffdfbf327760dc745fa1fc4/docs/demo/demo2.gif -------------------------------------------------------------------------------- /docs/demo/demo3.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidnguyen11/create-react-progressive-web-app/cfc38305f5e66f839ffdfbf327760dc745fa1fc4/docs/demo/demo3.gif -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "create-react-pwa-demo", 3 | "version": "0.1.0", 4 | "private": true, 5 | "devDependencies": { 6 | "react-scripts": "1.0.7" 7 | }, 8 | "dependencies": { 9 | "babel-polyfill": "^6.23.0", 10 | "bootstrap": "^3.3.7", 11 | "classnames": "^2.2.5", 12 | "cross-env": "^5.0.1", 13 | "material-ui": "^0.18.4", 14 | "offline-js": "^0.7.19", 15 | "react": "^15.6.1", 16 | "react-bootstrap": "^0.31.0", 17 | "react-dom": "^15.6.1", 18 | "react-helmet": "^5.1.3", 19 | "react-redux": "^5.0.5", 20 | "react-router": "^3.0.2", 21 | "react-router-redux": "^4.0.8", 22 | "react-router-scroll": "0.4.2", 23 | "react-tap-event-plugin": "^2.0.1", 24 | "redux": "^3.7.1", 25 | "redux-saga": "^0.15.4", 26 | "sanitize.css": "^5.0.0", 27 | "sw-toolbox": "^3.6.0", 28 | "whatwg-fetch": "^2.0.3" 29 | }, 30 | "scripts": { 31 | "start": "cross-env NODE_ENV=development NODE_PATH='./src' react-scripts start", 32 | "build": "cross-env NODE_ENV=production NODE_PATH='./src' react-scripts build", 33 | "test": "react-scripts test --env=jsdom", 34 | "eject": "react-scripts eject" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidnguyen11/create-react-progressive-web-app/cfc38305f5e66f839ffdfbf327760dc745fa1fc4/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 17 | React App 18 | 19 | 20 |
21 | 31 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "create-react-pwa", 3 | "name": "Create React PWApp", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | } 10 | ], 11 | "start_url": "/", 12 | "display": "standalone", 13 | "background_color": "#2d2d2d", 14 | "theme_color": "#61dafb" 15 | } 16 | -------------------------------------------------------------------------------- /public/service-worker.js: -------------------------------------------------------------------------------- 1 | importScripts('./sw-toolbox.js'); 2 | 3 | const ONE_WEEK = 604800; 4 | const ONE_HOUR = 3600; 5 | const CACHE_VERSION = 'v1.0'; 6 | const PREFIX = 'news'; 7 | 8 | const preCacheFiles = [ 9 | '/', 10 | '/favicon.ico', 11 | '/manifest.json', 12 | ]; 13 | 14 | toolbox.precache(preCacheFiles); 15 | 16 | const apiOptions = { 17 | cache: { 18 | name: 'api-cache-' + CACHE_VERSION, 19 | maxEntries: 200, 20 | maxAgeSeconds: ONE_HOUR, 21 | } 22 | }; 23 | 24 | const newsOptions = { 25 | cache: { 26 | name: PREFIX + '-cache-' + CACHE_VERSION, 27 | maxEntries: 200, 28 | maxAgeSeconds: ONE_WEEK, 29 | } 30 | }; 31 | 32 | // Install and Activate events 33 | self.addEventListener('install', (event) => event.waitUntil(self.skipWaiting())); 34 | self.addEventListener('activate', (event) => event.waitUntil(self.clients.claim())); 35 | 36 | // runtime cache 37 | // cache api 38 | toolbox.router.get(/^https?:\/\/(newsapi.org)\/v1/, toolbox.fastest, apiOptions); 39 | 40 | // cache local 41 | toolbox.router.get(/^https?:\/\/localhost/, toolbox.networkFirst, newsOptions); 42 | -------------------------------------------------------------------------------- /public/sw-toolbox.js: -------------------------------------------------------------------------------- 1 | 2 | // *** Start of auto-included sw-toolbox code. *** 3 | /* 4 | Copyright 2016 Google Inc. All Rights Reserved. 5 | 6 | Licensed under the Apache License, Version 2.0 (the "License"); 7 | you may not use this file except in compliance with the License. 8 | You may obtain a copy of the License at 9 | 10 | http://www.apache.org/licenses/LICENSE-2.0 11 | 12 | Unless required by applicable law or agreed to in writing, software 13 | distributed under the License is distributed on an "AS IS" BASIS, 14 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | See the License for the specific language governing permissions and 16 | limitations under the License. 17 | */!function(e){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=e();else if("function"==typeof define&&define.amd)define([],e);else{var t;t="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:this,t.toolbox=e()}}(function(){return function e(t,n,r){function o(c,s){if(!n[c]){if(!t[c]){var a="function"==typeof require&&require;if(!s&&a)return a(c,!0);if(i)return i(c,!0);var u=new Error("Cannot find module '"+c+"'");throw u.code="MODULE_NOT_FOUND",u}var f=n[c]={exports:{}};t[c][0].call(f.exports,function(e){var n=t[c][1][e];return o(n?n:e)},f,f.exports,e,t,n,r)}return n[c].exports}for(var i="function"==typeof require&&require,c=0;ct.value[l]){var r=t.value[p];c.push(r),a.delete(r),t.continue()}},s.oncomplete=function(){r(c)},s.onabort=o}):Promise.resolve([])}function s(e,t){return t?new Promise(function(n,r){var o=[],i=e.transaction(h,"readwrite"),c=i.objectStore(h),s=c.index(l),a=s.count();s.count().onsuccess=function(){var e=a.result;e>t&&(s.openCursor().onsuccess=function(n){var r=n.target.result;if(r){var i=r.value[p];o.push(i),c.delete(i),e-o.length>t&&r.continue()}})},i.oncomplete=function(){n(o)},i.onabort=r}):Promise.resolve([])}function a(e,t,n,r){return c(e,n,r).then(function(n){return s(e,t).then(function(e){return n.concat(e)})})}var u="sw-toolbox-",f=1,h="store",p="url",l="timestamp",d={};t.exports={getDb:o,setTimestampForUrl:i,expireEntries:a}},{}],3:[function(e,t,n){"use strict";function r(e){var t=a.match(e.request);t?e.respondWith(t(e.request)):a.default&&"GET"===e.request.method&&0===e.request.url.indexOf("http")&&e.respondWith(a.default(e.request))}function o(e){s.debug("activate event fired");var t=u.cache.name+"$$$inactive$$$";e.waitUntil(s.renameCache(t,u.cache.name))}function i(e){return e.reduce(function(e,t){return e.concat(t)},[])}function c(e){var t=u.cache.name+"$$$inactive$$$";s.debug("install event fired"),s.debug("creating cache ["+t+"]"),e.waitUntil(s.openCache({cache:{name:t}}).then(function(e){return Promise.all(u.preCacheItems).then(i).then(s.validatePrecacheInput).then(function(t){return s.debug("preCache list: "+(t.join(", ")||"(none)")),e.addAll(t)})}))}e("serviceworker-cache-polyfill");var s=e("./helpers"),a=e("./router"),u=e("./options");t.exports={fetchListener:r,activateListener:o,installListener:c}},{"./helpers":1,"./options":4,"./router":6,"serviceworker-cache-polyfill":16}],4:[function(e,t,n){"use strict";var r;r=self.registration?self.registration.scope:self.scope||new URL("./",self.location).href,t.exports={cache:{name:"$$$toolbox-cache$$$"+r+"$$$",maxAgeSeconds:null,maxEntries:null},debug:!1,networkTimeoutSeconds:null,preCacheItems:[],successResponses:/^0|([123]\d\d)|(40[14567])|410$/}},{}],5:[function(e,t,n){"use strict";var r=new URL("./",self.location),o=r.pathname,i=e("path-to-regexp"),c=function(e,t,n,r){t instanceof RegExp?this.fullUrlRegExp=t:(0!==t.indexOf("/")&&(t=o+t),this.keys=[],this.regexp=i(t,this.keys)),this.method=e,this.options=r,this.handler=n};c.prototype.makeHandler=function(e){var t;if(this.regexp){var n=this.regexp.exec(e);t={},this.keys.forEach(function(e,r){t[e.name]=n[r+1]})}return function(e){return this.handler(e,t,this.options)}.bind(this)},t.exports=c},{"path-to-regexp":15}],6:[function(e,t,n){"use strict";function r(e){return e.replace(/[-\/\\^$*+?.()|[\]{}]/g,"\\$&")}var o=e("./route"),i=e("./helpers"),c=function(e,t){for(var n=e.entries(),r=n.next(),o=[];!r.done;){var i=new RegExp(r.value[0]);i.test(t)&&o.push(r.value[1]),r=n.next()}return o},s=function(){this.routes=new Map,this.routes.set(RegExp,new Map),this.default=null};["get","post","put","delete","head","any"].forEach(function(e){s.prototype[e]=function(t,n,r){return this.add(e,t,n,r)}}),s.prototype.add=function(e,t,n,c){c=c||{};var s;t instanceof RegExp?s=RegExp:(s=c.origin||self.location.origin,s=s instanceof RegExp?s.source:r(s)),e=e.toLowerCase();var a=new o(e,t,n,c);this.routes.has(s)||this.routes.set(s,new Map);var u=this.routes.get(s);u.has(e)||u.set(e,new Map);var f=u.get(e),h=a.regexp||a.fullUrlRegExp;f.has(h.source)&&i.debug('"'+t+'" resolves to same regex as existing route.'),f.set(h.source,a)},s.prototype.matchMethod=function(e,t){var n=new URL(t),r=n.origin,o=n.pathname;return this._match(e,c(this.routes,r),o)||this._match(e,[this.routes.get(RegExp)],t)},s.prototype._match=function(e,t,n){if(0===t.length)return null;for(var r=0;r0)return s[0].makeHandler(n)}}return null},s.prototype.match=function(e){return this.matchMethod(e.method,e.url)||this.matchMethod("any",e.url)},t.exports=new s},{"./helpers":1,"./route":5}],7:[function(e,t,n){"use strict";function r(e,t,n){return o.debug("Strategy: cache first ["+e.url+"]",n),o.openCache(n).then(function(t){return t.match(e).then(function(t){return t?t:o.fetchAndCache(e,n)})})}var o=e("../helpers");t.exports=r},{"../helpers":1}],8:[function(e,t,n){"use strict";function r(e,t,n){return o.debug("Strategy: cache only ["+e.url+"]",n),o.openCache(n).then(function(t){return t.match(e)})}var o=e("../helpers");t.exports=r},{"../helpers":1}],9:[function(e,t,n){"use strict";function r(e,t,n){return o.debug("Strategy: fastest ["+e.url+"]",n),new Promise(function(r,c){var s=!1,a=[],u=function(e){a.push(e.toString()),s?c(new Error('Both cache and network failed: "'+a.join('", "')+'"')):s=!0},f=function(e){e instanceof Response?r(e):u("No result returned")};o.fetchAndCache(e.clone(),n).then(f,u),i(e,t,n).then(f,u)})}var o=e("../helpers"),i=e("./cacheOnly");t.exports=r},{"../helpers":1,"./cacheOnly":8}],10:[function(e,t,n){t.exports={networkOnly:e("./networkOnly"),networkFirst:e("./networkFirst"),cacheOnly:e("./cacheOnly"),cacheFirst:e("./cacheFirst"),fastest:e("./fastest")}},{"./cacheFirst":7,"./cacheOnly":8,"./fastest":9,"./networkFirst":11,"./networkOnly":12}],11:[function(e,t,n){"use strict";function r(e,t,n){n=n||{};var r=n.successResponses||o.successResponses,c=n.networkTimeoutSeconds||o.networkTimeoutSeconds;return i.debug("Strategy: network first ["+e.url+"]",n),i.openCache(n).then(function(t){var o,s,a=[];if(c){var u=new Promise(function(n){o=setTimeout(function(){t.match(e).then(function(e){e&&n(e)})},1e3*c)});a.push(u)}var f=i.fetchAndCache(e,n).then(function(e){if(o&&clearTimeout(o),r.test(e.status))return e;throw i.debug("Response was an HTTP error: "+e.statusText,n),s=e,new Error("Bad response")}).catch(function(r){return i.debug("Network or response error, fallback to cache ["+e.url+"]",n),t.match(e).then(function(e){if(e)return e;if(s)return s;throw r})});return a.push(f),Promise.race(a)})}var o=e("../options"),i=e("../helpers");t.exports=r},{"../helpers":1,"../options":4}],12:[function(e,t,n){"use strict";function r(e,t,n){return o.debug("Strategy: network only ["+e.url+"]",n),fetch(e)}var o=e("../helpers");t.exports=r},{"../helpers":1}],13:[function(e,t,n){"use strict";var r=e("./options"),o=e("./router"),i=e("./helpers"),c=e("./strategies"),s=e("./listeners");i.debug("Service Worker Toolbox is loading"),self.addEventListener("install",s.installListener),self.addEventListener("activate",s.activateListener),self.addEventListener("fetch",s.fetchListener),t.exports={networkOnly:c.networkOnly,networkFirst:c.networkFirst,cacheOnly:c.cacheOnly,cacheFirst:c.cacheFirst,fastest:c.fastest,router:o,options:r,cache:i.cache,uncache:i.uncache,precache:i.precache}},{"./helpers":1,"./listeners":3,"./options":4,"./router":6,"./strategies":10}],14:[function(e,t,n){t.exports=Array.isArray||function(e){return"[object Array]"==Object.prototype.toString.call(e)}},{}],15:[function(e,t,n){function r(e){for(var t,n=[],r=0,o=0,i="";null!=(t=x.exec(e));){var c=t[0],s=t[1],a=t.index;if(i+=e.slice(o,a),o=a+c.length,s)i+=s[1];else{var f=e[o],h=t[2],p=t[3],l=t[4],d=t[5],g=t[6],m=t[7];i&&(n.push(i),i="");var v=null!=h&&null!=f&&f!==h,w="+"===g||"*"===g,y="?"===g||"*"===g,b=t[2]||"/",E=l||d||(m?".*":"[^"+b+"]+?");n.push({name:p||r++,prefix:h||"",delimiter:b,optional:y,repeat:w,partial:v,asterisk:!!m,pattern:u(E)})}}return o=46||"Chrome"===n&&r>=50)||(Cache.prototype.addAll=function(e){function t(e){this.name="NetworkError",this.code=19,this.message=e}var n=this;return t.prototype=Object.create(Error.prototype),Promise.resolve().then(function(){if(arguments.length<1)throw new TypeError;return e=e.map(function(e){return e instanceof Request?e:String(e)}),Promise.all(e.map(function(e){"string"==typeof e&&(e=new Request(e));var n=new URL(e.url).protocol;if("http:"!==n&&"https:"!==n)throw new t("Invalid scheme");return fetch(e.clone())}))}).then(function(r){if(r.some(function(e){return!e.ok}))throw new t("Incorrect response status");return Promise.all(r.map(function(t,r){return n.put(e[r],t)}))}).then(function(){})},Cache.prototype.add=function(e){return this.addAll([e])})}()},{}]},{},[13])(13)}); -------------------------------------------------------------------------------- /src/assets/images/ars-technica.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidnguyen11/create-react-progressive-web-app/cfc38305f5e66f839ffdfbf327760dc745fa1fc4/src/assets/images/ars-technica.png -------------------------------------------------------------------------------- /src/assets/images/sosub.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidnguyen11/create-react-progressive-web-app/cfc38305f5e66f839ffdfbf327760dc745fa1fc4/src/assets/images/sosub.png -------------------------------------------------------------------------------- /src/assets/images/techcrunch.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidnguyen11/create-react-progressive-web-app/cfc38305f5e66f839ffdfbf327760dc745fa1fc4/src/assets/images/techcrunch.jpg -------------------------------------------------------------------------------- /src/assets/images/tnw.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidnguyen11/create-react-progressive-web-app/cfc38305f5e66f839ffdfbf327760dc745fa1fc4/src/assets/images/tnw.jpg -------------------------------------------------------------------------------- /src/components/CardComponent/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Card, CardActions, CardHeader, CardMedia, CardTitle, CardText } from 'material-ui/Card'; 3 | import FlatButton from 'material-ui/FlatButton'; 4 | import './styles.css'; 5 | 6 | function CardComponent(props) { 7 | const { title, urlToImage, description, url, author, publishedDate } = props; 8 | return ( 9 | 10 | 14 | 15 | }> 16 | {urlToImage && {title}} 17 | 18 | 19 | 20 | 21 | {description} 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | ) 30 | } 31 | 32 | export default CardComponent; -------------------------------------------------------------------------------- /src/components/CardComponent/styles.css: -------------------------------------------------------------------------------- 1 | .cardItem { 2 | margin-bottom: 20px; 3 | } -------------------------------------------------------------------------------- /src/components/Header/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { Link } from 'react-router'; 3 | import AppBar from 'material-ui/AppBar'; 4 | import Drawer from 'material-ui/Drawer'; 5 | import MenuItem from 'material-ui/MenuItem'; 6 | import './styles.css'; 7 | 8 | class Header extends Component { 9 | state = { 10 | open: false, 11 | } 12 | 13 | handleToggle = () => this.setState({open: !this.state.open}); 14 | 15 | handleClose = () => this.setState({open: false}); 16 | 17 | render() { 18 | const { menuItems } = this.props; 19 | return ( 20 |
21 | 22 | this.setState({open})} 27 | > 28 | {menuItems.map((item, index) => {item.title})} 29 | 30 |
31 | ) 32 | } 33 | } 34 | 35 | Header.defaultProps = { 36 | menuItems: [], 37 | }; 38 | 39 | export default Header; 40 | -------------------------------------------------------------------------------- /src/components/Header/styles.css: -------------------------------------------------------------------------------- 1 | .header { 2 | margin-bottom: 20px; 3 | } -------------------------------------------------------------------------------- /src/components/NoInternet/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | function NoInternet() { 4 | return ( 5 |

No Internet

6 | ) 7 | } 8 | 9 | export default NoInternet; 10 | -------------------------------------------------------------------------------- /src/components/index.js: -------------------------------------------------------------------------------- 1 | import Header from './Header'; 2 | import CardComponent from './CardComponent'; 3 | import NoInternet from './NoInternet'; 4 | 5 | export { 6 | Header, 7 | CardComponent, 8 | NoInternet, 9 | }; 10 | -------------------------------------------------------------------------------- /src/containers/AppWrapper/index.js: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react'; 2 | import Helmet from 'react-helmet'; 3 | import MuiThemeProvider from 'material-ui/styles/MuiThemeProvider'; 4 | import { Grid } from 'react-bootstrap'; 5 | import Snackbar from 'material-ui/Snackbar'; 6 | 7 | import { Header } from 'components'; 8 | import { menuItems, appleMetas, linkPwaMetas } from 'utils/constants'; 9 | import './styles.css'; 10 | 11 | const muStyles = { 12 | contentStyle: { 13 | textAlign: 'center' 14 | } 15 | }; 16 | 17 | class AppWrapper extends PureComponent { 18 | state = { 19 | online: true, 20 | showSnackbar: false, 21 | }; 22 | 23 | componentWillMount() { 24 | if (!window.Offline.check()) { 25 | this.setState({ 26 | online: false, 27 | showSnackbar: true, 28 | }); 29 | } 30 | } 31 | 32 | componentDidMount() { 33 | window.Offline.on('down', () => { 34 | this.setState({ 35 | online: false, 36 | showSnackbar: true, 37 | }); 38 | }); 39 | window.Offline.on('up', () => { 40 | this.setState({ 41 | online: true, 42 | showSnackbar: true, 43 | }); 44 | }); 45 | } 46 | 47 | handleRequestClose = () => { 48 | this.setState({ 49 | showSnackbar: false, 50 | }); 51 | } 52 | 53 | renderOffOnlineSnackbar() { 54 | const { online, showSnackbar } = this.state; 55 | const msg = !online ? 'Offline' : 'Online'; 56 | return ( 57 | 64 | ); 65 | } 66 | 67 | render() { 68 | const metas = [...appleMetas]; 69 | const links = [...linkPwaMetas]; 70 | const { online } = this.state; 71 | const className = !online ? 'main-container offline' : 'main-container'; 72 | return ( 73 | 74 |
75 | 76 |
77 | 78 | {this.props.children} 79 | 80 | {this.renderOffOnlineSnackbar()} 81 |
82 |
83 | ) 84 | } 85 | } 86 | 87 | export default AppWrapper; 88 | -------------------------------------------------------------------------------- /src/containers/AppWrapper/styles.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | height: 100%; 3 | width: 100%; 4 | } 5 | 6 | .main-container.offline { 7 | -webkit-filter: grayscale(100%); 8 | filter: grayscale(100%); 9 | } -------------------------------------------------------------------------------- /src/containers/HomePage/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router'; 3 | import { menuItems } from 'utils/constants'; 4 | import './styles.css'; 5 | 6 | function HomePage() { 7 | return ( 8 |
9 | {menuItems.map((item, index) => 10 | 11 | {item.title} 12 | 13 | )} 14 |
15 | ); 16 | } 17 | 18 | export default HomePage; -------------------------------------------------------------------------------- /src/containers/HomePage/styles.css: -------------------------------------------------------------------------------- 1 | .homePage { 2 | text-align: center; 3 | } 4 | 5 | .homePage img { 6 | width: 200px; 7 | margin-right: 15px; 8 | margin-bottom: 15px; 9 | } -------------------------------------------------------------------------------- /src/containers/NewsListing/actions.js: -------------------------------------------------------------------------------- 1 | import { 2 | FETCH_DATA, 3 | FETCH_DATA_SUCCESS, 4 | FETCH_DATA_FAIL, 5 | } from './constants'; 6 | 7 | export function fetchData(source) { 8 | return { 9 | type: FETCH_DATA, 10 | payload: { 11 | source, 12 | } 13 | }; 14 | } 15 | 16 | export function fetchDataSuccess(response) { 17 | return { 18 | type: FETCH_DATA_SUCCESS, 19 | payload: { 20 | response 21 | }, 22 | }; 23 | } 24 | 25 | export function fetchDataFail() { 26 | return { 27 | type: FETCH_DATA_FAIL, 28 | }; 29 | } 30 | -------------------------------------------------------------------------------- /src/containers/NewsListing/constants.js: -------------------------------------------------------------------------------- 1 | export const FETCH_DATA = 'src/containers/TechCrunch/FETCH_DATA'; 2 | export const FETCH_DATA_SUCCESS = 'src/containers/TechCrunch/FETCH_DATA_SUCCESS'; 3 | export const FETCH_DATA_FAIL = 'src/containers/TechCrunch/FETCH_DATA_FAIL'; 4 | -------------------------------------------------------------------------------- /src/containers/NewsListing/index.js: -------------------------------------------------------------------------------- 1 | import './styles.css'; 2 | import React, { Component } from 'react'; 3 | import { connect } from 'react-redux'; 4 | import CircularProgress from 'material-ui/CircularProgress'; 5 | 6 | import { CardComponent, NoInternet } from 'components'; 7 | import Paper from 'material-ui/Paper'; 8 | 9 | const muStyles = { 10 | padding: 50, 11 | textAlign: 'center', 12 | marginBottom: 20, 13 | }; 14 | 15 | class NewsListing extends Component { 16 | renderLoading() { 17 | const { fetching } = this.props; 18 | return ( 19 | fetching && 20 | 21 | 22 | 23 | ); 24 | } 25 | 26 | render() { 27 | const { articles } = this.props; 28 | let element = null; 29 | if (!navigator.onLine) { 30 | element = 31 | } else { 32 | element = articles.map((item, index) => ); 33 | } 34 | return ( 35 |
36 | {element} 37 | {this.renderLoading()} 38 |
39 | ) 40 | } 41 | } 42 | 43 | const mapStateToProps = (state) => ({ 44 | newsState: state.newsReducer, 45 | articles: state.newsReducer.articles, 46 | source: state.newsReducer.source, 47 | fetching: state.newsReducer.fetching, 48 | }); 49 | 50 | const mapDispatchToProps = (dispatch) => ({ 51 | dispatch, 52 | }); 53 | 54 | export default connect(mapStateToProps, mapDispatchToProps)(NewsListing); 55 | -------------------------------------------------------------------------------- /src/containers/NewsListing/reducer.js: -------------------------------------------------------------------------------- 1 | import { 2 | FETCH_DATA_SUCCESS, 3 | } from './constants'; 4 | 5 | const initialState = { 6 | articles: [], 7 | source: null, 8 | fetching: true, 9 | }; 10 | 11 | function newsReducer(state = initialState, action) { 12 | switch(action.type) { 13 | case FETCH_DATA_SUCCESS: { 14 | return { 15 | ...state, 16 | articles: action.payload.response.articles, 17 | source: action.payload.response.source, 18 | fetching: false, 19 | } 20 | } 21 | default: { 22 | return state; 23 | } 24 | } 25 | } 26 | 27 | export default newsReducer; 28 | -------------------------------------------------------------------------------- /src/containers/NewsListing/sagas.js: -------------------------------------------------------------------------------- 1 | import { call, put, takeLatest } from 'redux-saga/effects' 2 | import { 3 | FETCH_DATA, 4 | } from './constants'; 5 | import { 6 | fetchDataSuccess, 7 | fetchDataFail, 8 | } from './actions'; 9 | import { configs } from 'utils/configs'; 10 | import request from 'utils/request'; 11 | import { 12 | prettyDisplayDate 13 | } from 'utils'; 14 | 15 | function normalizeData(rawData) { 16 | return rawData.map(item => { 17 | item.publishedDate = `Published at ${prettyDisplayDate(item.publishedAt)}`; 18 | return item; 19 | }); 20 | } 21 | 22 | function* fetchListItem(action) { 23 | try { 24 | const { source } = action.payload; 25 | const requestURL = `${configs.apiUrl}/articles?source=${source}&apiKey=${configs.newsApiKey}`; 26 | const response = yield call(request, requestURL); 27 | response.articles = normalizeData(response.articles); 28 | yield put(fetchDataSuccess(response)); 29 | } catch (err) { 30 | yield put(fetchDataFail(err)); 31 | } 32 | } 33 | 34 | function* getNewsData() { 35 | // const watcher = yield takeLatest(FETCH_DATA, fetchListItem); 36 | // // Suspend execution until location changes 37 | // yield take(LOCATION_CHANGE); 38 | // yield cancel(watcher); 39 | yield takeLatest(FETCH_DATA, fetchListItem); 40 | } 41 | 42 | export default getNewsData; 43 | -------------------------------------------------------------------------------- /src/containers/NewsListing/styles.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidnguyen11/create-react-progressive-web-app/cfc38305f5e66f839ffdfbf327760dc745fa1fc4/src/containers/NewsListing/styles.css -------------------------------------------------------------------------------- /src/containers/NotFound/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | function NotFound() { 4 | return ( 5 |

Not Found

6 | ) 7 | } 8 | 9 | export default NotFound; 10 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import 'babel-polyfill'; 2 | import 'offline-js'; 3 | 4 | import 'sanitize.css/sanitize.css'; 5 | import 'bootstrap/dist/css/bootstrap.css'; 6 | import 'bootstrap/dist/css/bootstrap-theme.css'; 7 | 8 | import reducer from './reducers'; 9 | import sagas from './sagas'; 10 | import Routes from './routes'; 11 | 12 | import React from 'react'; 13 | import { applyMiddleware, createStore } from 'redux'; 14 | import createSagaMiddleware from 'redux-saga'; 15 | import ReactDOM from 'react-dom'; 16 | import { applyRouterMiddleware, browserHistory } from 'react-router'; 17 | import { useScroll } from 'react-router-scroll'; 18 | import { syncHistoryWithStore} from 'react-router-redux'; 19 | import { Provider } from 'react-redux' 20 | import injectTapEventPlugin from 'react-tap-event-plugin'; 21 | injectTapEventPlugin(); 22 | 23 | const sagaMiddleware = createSagaMiddleware(); 24 | 25 | const store = createStore( 26 | reducer, 27 | applyMiddleware(sagaMiddleware), 28 | ); 29 | 30 | // Begin our Index Saga 31 | sagaMiddleware.run(sagas); 32 | 33 | // sync history 34 | const history = syncHistoryWithStore(browserHistory, store); 35 | 36 | const routerProps = { 37 | history, 38 | render: applyRouterMiddleware(useScroll()), 39 | store, 40 | sagas, 41 | }; 42 | 43 | ReactDOM.render( 44 | 45 | 46 | , 47 | document.getElementById('root') 48 | ); 49 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/reducers.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux' 2 | import { routerReducer } from 'react-router-redux'; 3 | import newsReducer from './containers/NewsListing/reducer'; 4 | 5 | export default combineReducers({ 6 | routing: routerReducer, 7 | newsReducer, 8 | }); 9 | -------------------------------------------------------------------------------- /src/routes.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Router, Route } from 'react-router'; 3 | import { 4 | fetchData 5 | } from './containers/NewsListing/actions'; 6 | import AppWrapper from './containers/AppWrapper'; 7 | import NewsListing from './containers/NewsListing'; 8 | import HomePage from './containers/HomePage'; 9 | import NotFound from './containers/NotFound'; 10 | 11 | const Routes = (props) => { 12 | const { store } = props; 13 | 14 | const getDataNews = (nextState, replace, cb) => { 15 | console.log('nextState', nextState); 16 | const { params: { source } } = nextState; 17 | store.dispatch(fetchData(source)); 18 | cb(); 19 | }; 20 | 21 | return ( 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | ) 30 | }; 31 | 32 | export default Routes; 33 | -------------------------------------------------------------------------------- /src/sagas.js: -------------------------------------------------------------------------------- 1 | import NewListing from './containers/NewsListing/sagas'; 2 | 3 | export default function* AppSaga () { 4 | yield [ 5 | NewListing(), 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /src/utils/configs.js: -------------------------------------------------------------------------------- 1 | const env = process.env.NODE_ENV || 'development'; 2 | 3 | export const configs = { 4 | development: { 5 | apiUrl: 'https://newsapi.org/v1', 6 | newsApiKey: '2c19465b0ba44c10a0e873a0234bddc8', 7 | }, 8 | production: { 9 | apiUrl: 'https://newsapi.org/v1', 10 | newsApiKey: '2c19465b0ba44c10a0e873a0234bddc8', 11 | }, 12 | }[env]; 13 | -------------------------------------------------------------------------------- /src/utils/constants.js: -------------------------------------------------------------------------------- 1 | const tnwLogo = require('assets/images/tnw.jpg'); 2 | const tcLogo = require('assets/images/techcrunch.jpg'); 3 | const atLogo = require('assets/images/ars-technica.png'); 4 | 5 | export const appleMetas = [ 6 | { 7 | name: 'theme-color', 8 | content: '#fdce09', 9 | }, 10 | { 11 | name: 'full-screen', 12 | content: 'yes', 13 | }, 14 | { 15 | name: 'apple-mobile-web-app-capable', 16 | content: 'yes', 17 | }, 18 | { 19 | name: 'mobile-web-app-capable', 20 | content: 'yes', 21 | }, 22 | { 23 | name: 'apple-mobile-web-app-title', 24 | content: 'News', 25 | }, 26 | { 27 | name: 'apple-mobile-web-app-status-bar-style', 28 | content: '#fdce09' 29 | } 30 | ]; 31 | 32 | export const linkPwaMetas = [ 33 | { 34 | rel: 'apple-touch-icon', 35 | href: '/favicon.ico', 36 | } 37 | ]; 38 | 39 | export const menuItems = [ 40 | { 41 | title: 'The Next Web', 42 | url: '/the-next-web', 43 | logo: tnwLogo, 44 | }, 45 | { 46 | title: 'TechCrunch', 47 | url: '/techcrunch', 48 | logo: tcLogo, 49 | }, 50 | { 51 | title: 'Ars Technica', 52 | url: '/ars-technica', 53 | logo: atLogo, 54 | }, 55 | ]; 56 | -------------------------------------------------------------------------------- /src/utils/index.js: -------------------------------------------------------------------------------- 1 | export function normalizeData(rawData) { 2 | return rawData.map(item => { 3 | item.publishedDate = `Published at ${prettyDisplayDate(item.publishedAt)}`; 4 | return item; 5 | }); 6 | } 7 | 8 | export function prettyDisplayDate(dateStr) { 9 | return dateStr.substr(0, 10); 10 | } 11 | -------------------------------------------------------------------------------- /src/utils/request.js: -------------------------------------------------------------------------------- 1 | import 'whatwg-fetch'; 2 | 3 | /** 4 | * Parses the JSON returned by a network request 5 | * 6 | * @param {object} response A response from a network request 7 | * 8 | * @return {object} The parsed JSON from the request 9 | */ 10 | function parseJSON(response) { 11 | return response.json(); 12 | } 13 | 14 | /** 15 | * Checks if a network request came back fine, and throws an error if not 16 | * 17 | * @param {object} response A response from a network request 18 | * 19 | * @return {object|undefined} Returns either the response, or throws an error 20 | */ 21 | function checkStatus(response) { 22 | if (response.status >= 200 && response.status < 300) { 23 | return response; 24 | } 25 | 26 | const error = new Error(response.statusText); 27 | error.response = response; 28 | throw error; 29 | } 30 | 31 | /** 32 | * Requests a URL, returning a promise 33 | * 34 | * @param {string} url The URL we want to request 35 | * @param {object} [options] The options we want to pass to "fetch" 36 | * 37 | * @return {object} The response data 38 | */ 39 | export default function request(url, options) { 40 | return fetch(url, options) 41 | .then(checkStatus) 42 | .then(parseJSON); 43 | } --------------------------------------------------------------------------------