├── .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 | [](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 | 
78 |
79 | ## Demo 3
80 | Touch icon in `Add to home screen` mode
81 |
82 | 
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 &&
}
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) => )}
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 |

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 |
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 | }
--------------------------------------------------------------------------------