├── .editorconfig ├── .firebaserc ├── .gitignore ├── firebase.json ├── firebase ├── database.rules.json ├── firestore.indexes.json ├── firestore.rules └── storage.rules ├── package.json ├── public └── index.html ├── readme.md ├── rollup.config.js ├── src ├── app-router.ts ├── app-shell.ts ├── index.ts ├── polyfill.js └── store │ ├── auth │ ├── actions.ts │ ├── index.ts │ ├── middleware.ts │ ├── reducer.ts │ └── selectors.ts │ ├── connect.ts │ ├── firestore.ts │ ├── index.ts │ ├── middleware.ts │ ├── reducer.ts │ ├── router.ts │ └── storage.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | charset = utf-8 7 | indent_style = space 8 | indent_size = 2 9 | -------------------------------------------------------------------------------- /.firebaserc: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "default": "web-app-starter" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .rpt2_cache 2 | node_modules 3 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "database": { 3 | "rules": "firebase/database.rules.json" 4 | }, 5 | "firestore": { 6 | "rules": "firebase/firestore.rules", 7 | "indexes": "firebase/firestore.indexes.json" 8 | }, 9 | "hosting": { 10 | "public": "public", 11 | "ignore": [ 12 | "firebase.json", 13 | "**/.*", 14 | "**/node_modules/**" 15 | ], 16 | "rewrites": [ 17 | { 18 | "source": "**", 19 | "destination": "/index.html" 20 | } 21 | ] 22 | }, 23 | "storage": { 24 | "rules": "firebase/storage.rules" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /firebase/database.rules.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | ".read": "auth != null", 4 | ".write": "auth != null" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /firebase/firestore.indexes.json: -------------------------------------------------------------------------------- 1 | { 2 | // Example: 3 | // 4 | // "indexes": [ 5 | // { 6 | // "collectionId": "widgets", 7 | // "fields": [ 8 | // { "fieldPath": "foo", "mode": "ASCENDING" }, 9 | // { "fieldPath": "bar", "mode": "DESCENDING" } 10 | // ] 11 | // } 12 | // ] 13 | "indexes": [] 14 | } 15 | -------------------------------------------------------------------------------- /firebase/firestore.rules: -------------------------------------------------------------------------------- 1 | service cloud.firestore { 2 | match /databases/{database}/documents { 3 | match /{document=**} { 4 | allow read, write; 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /firebase/storage.rules: -------------------------------------------------------------------------------- 1 | service firebase.storage { 2 | match /b/{bucket}/o { 3 | match /{allPaths=**} { 4 | allow read, write: if request.auth!=null; 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web-app-starter", 3 | "version": "1.0.0", 4 | "description": "Web App Starter using WebComponents, Lit-Html, Redux, Typescript and Rollup", 5 | "main": "index.js", 6 | "scripts": { 7 | "dev": "rollup -c -w", 8 | "build": "rollup -c", 9 | "test": "echo \"Error: no test specified\" && exit 1" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/CaptainCodeman/web-app-starter.git" 14 | }, 15 | "keywords": [ 16 | "web-components", 17 | "lit-html", 18 | "redux", 19 | "typescript", 20 | "rollup" 21 | ], 22 | "author": "simon@captaincodeman.com", 23 | "license": "ISC", 24 | "bugs": { 25 | "url": "https://github.com/CaptainCodeman/web-app-starter/issues" 26 | }, 27 | "homepage": "https://github.com/CaptainCodeman/web-app-starter#readme", 28 | "dependencies": { 29 | "@webcomponents/webcomponentsjs": "git://github.com/webcomponents/webcomponentsjs.git#no-html-imports", 30 | "firebase": "^4.6.2", 31 | "lit-html": "^0.7.1", 32 | "lit-html-element": "git://github.com/CaptainCodeman/lit-element.git#rollup-build", 33 | "redux": "^3.7.2", 34 | "redux-first-routing": "^0.3.0", 35 | "redux-thunk": "^2.2.0", 36 | "reselect": "^3.0.1", 37 | "universal-router": "^5.0.0" 38 | }, 39 | "devDependencies": { 40 | "rollup": "^0.52.0", 41 | "rollup-plugin-commonjs": "^8.2.6", 42 | "rollup-plugin-node-resolve": "^3.0.0", 43 | "rollup-plugin-replace": "^2.0.0", 44 | "rollup-plugin-typescript2": "^0.8.2", 45 | "rollup-plugin-uglify": "^2.0.1", 46 | "typescript": "^2.5.3", 47 | "uglify-es": "^3.1.3" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Web App Starter 5 | 6 | 7 | 8 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Web App Starter 2 | 3 | A skeleton application showing a "modern" web app project based 4 | on Web-Components enhanced with Lit-Html for template rendering 5 | and using Redux for state with Universal Router for routing and 6 | Firebase Firestore for storage. All developed in Typescript and 7 | built using Rollup. 8 | 9 | App JS size: 14Kb (not including Firebase Libraries) 10 | 11 | See [demo](https://web-app-starter.firebaseapp.com/) 12 | 13 | ## Dependecies 14 | 15 | Firebase CLI 16 | 17 | ## Getting Started 18 | 19 | Install dependencies 20 | 21 | npm install 22 | 23 | Build 24 | 25 | npm run build 26 | 27 | Develop (build & watch) 28 | 29 | npm run dev 30 | 31 | Build output is in `/public` folder 32 | 33 | ## Running 34 | 35 | Start the app using the firebase server, passing the firebase project ID (or 36 | by configuring it in .firebaserc) 37 | 38 | firebase serve --project firebase-project-id 39 | 40 | View the running app by going to http://localhost:5000 41 | 42 | ## Credits 43 | 44 | App Drawer animation based on 45 | [this article](https://medium.com/outsystems-experts/how-to-achieve-60-fps-animations-with-css3-db7b98610108) 46 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import * as path from 'path'; 4 | import resolve from 'rollup-plugin-node-resolve'; 5 | import commonjs from 'rollup-plugin-commonjs'; 6 | import replace from 'rollup-plugin-replace'; 7 | import typescript from 'rollup-plugin-typescript2'; 8 | import uglify from 'rollup-plugin-uglify'; 9 | import { minify } from 'uglify-es'; 10 | 11 | export default { 12 | input: 'src/index.ts', 13 | output: { 14 | file: 'public/app.min.js', 15 | format: 'iife', 16 | name: 'app', 17 | sourcemap: true, 18 | }, 19 | external: ['firebase/app'], 20 | globals: { 21 | 'firebase/app': 'firebase' 22 | }, 23 | plugins: [ 24 | replace({ 25 | 'process.env.NODE_ENV': JSON.stringify('production') 26 | }), 27 | resolve({ 28 | jsnext: true, 29 | main: true 30 | }), 31 | commonjs({ 32 | include: 'node_modules/**', 33 | }), 34 | typescript({ 35 | typescript: require('typescript'), 36 | tsconfigOverride: { 37 | compilerOptions: { } 38 | } 39 | }), 40 | uglify({}, minify), 41 | ], 42 | } 43 | -------------------------------------------------------------------------------- /src/app-router.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html } from 'lit-html-element'; 2 | import { unsafeHTML } from 'lit-html/lib/unsafe-html'; 3 | import UniversalRouter from 'universal-router'; 4 | import { RootState } from './store'; 5 | import { connect } from './store/connect'; 6 | 7 | export default () => { 8 | class Router extends HTMLElement { 9 | private routes = [ 10 | { 11 | path: '/', 12 | action: () => '

Home

', 13 | }, 14 | { 15 | path: '/foo', 16 | action: () => console.log('checking child routes for /foo'), 17 | children: [ 18 | { 19 | path: '', 20 | action: () => '

Foo

', 21 | }, 22 | { 23 | path: '/:id', 24 | action: (context : any) => `

Foo ${context.params.id}

`, 25 | }, 26 | ], 27 | }, 28 | ] 29 | 30 | private router : UniversalRouter 31 | 32 | constructor() { 33 | super() 34 | this.attachShadow({ mode: 'open' }); 35 | this.router = new UniversalRouter(this.routes); 36 | } 37 | 38 | shouldUpdateProps(state : RootState) : boolean { 39 | return this.path !== state.router.pathname 40 | } 41 | 42 | mapStateToProps(state : RootState) : any { 43 | return { 44 | path: state.router.pathname 45 | } 46 | } 47 | 48 | private _path : string 49 | 50 | get path() : string { return this._path } 51 | set path(val : string) { this._path = val, this.render() } 52 | 53 | render() { 54 | this.router.resolve(this._path).then(html => this.shadowRoot.innerHTML = html) 55 | } 56 | } 57 | 58 | customElements.define('app-router', connect(Router)); 59 | } 60 | -------------------------------------------------------------------------------- /src/app-shell.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html } from 'lit-html-element'; 2 | import { connect } from './store/connect'; 3 | import store, { signIn, signOut, anonymous, authenticated, RootState } from './store'; 4 | 5 | export default () => { 6 | class AppShell extends LitElement { 7 | private _opened : boolean 8 | private _animatable : boolean 9 | private toggleClassMenu : Function 10 | private transitionEnd : Function 11 | private statusKnown : boolean 12 | private user : object 13 | private anonymous : boolean 14 | private authenticated : boolean 15 | 16 | static get is() { return 'app-shell' } 17 | 18 | static get properties() { 19 | return { 20 | statusKnown: { 21 | type: Boolean 22 | }, 23 | user: { 24 | type: Object 25 | } 26 | } 27 | } 28 | 29 | constructor() { 30 | super() 31 | this._opened = false; 32 | this.toggleClassMenu = this._toggleClassMenu.bind(this); 33 | this.transitionEnd = this._transitionEnd.bind(this); 34 | } 35 | 36 | mapStateToProps(state : RootState) : any { 37 | return { 38 | statusKnown: state.auth.statusKnown, 39 | user: state.auth.user, 40 | anonymous: anonymous(state), 41 | authenticated: authenticated(state), 42 | } 43 | } 44 | 45 | signIn(e) { 46 | store.dispatch(signIn('google')) 47 | } 48 | 49 | signOut(e) { 50 | store.dispatch(signOut()) 51 | } 52 | 53 | get menuClass() { 54 | return (this._opened ? 'menu--visible' : '') + 55 | (this._animatable ? ' menu--animatable' : ''); 56 | } 57 | 58 | _toggleClassMenu(e : Event) { 59 | this._animatable = true; 60 | this._opened = !this._opened; 61 | this.invalidate(); 62 | } 63 | 64 | _transitionEnd(e : Event) { 65 | this._animatable = false; 66 | this.invalidate(); 67 | } 68 | 69 | json(val) { 70 | return JSON.stringify(val, null, ' ') 71 | } 72 | 73 | render() { 74 | return html` 75 | 196 | 205 |
206 | 212 |
213 | 214 |

215 | 216 | 217 |

218 |

219 | Status Known: ${this.statusKnown} 220 | Anonymous: ${this.anonymous} 221 | Authenticated: ${this.authenticated} 222 |

223 |
${this.json(this.user)}
224 |
225 |
`; 226 | } 227 | } 228 | 229 | AppShell.withProperties() 230 | 231 | customElements.define('app-shell', connect(AppShell)); 232 | } 233 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import './polyfill'; 2 | import app from './app-shell'; 3 | import router from './app-router'; 4 | 5 | function bootstrap() { 6 | app() 7 | router() 8 | } 9 | 10 | declare global { 11 | interface Window { WebComponents: any; } 12 | } 13 | 14 | if (window.WebComponents && window.WebComponents.ready) { 15 | bootstrap(); 16 | } else { 17 | window.addEventListener('WebComponentsReady', bootstrap); 18 | } 19 | -------------------------------------------------------------------------------- /src/polyfill.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @license 3 | * Copyright (c) 2017 The Polymer Project Authors. All rights reserved. 4 | * This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt 5 | * The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt 6 | * The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt 7 | * Code distributed by Google as part of the polymer project is also 8 | * subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt 9 | */ 10 | 11 | (function() { 12 | 'use strict'; 13 | // global for (1) existence means `WebComponentsReady` will file, 14 | // (2) WebComponents.ready == true means event has fired. 15 | window.WebComponents = window.WebComponents || {}; 16 | var path = 'node_modules/@webcomponents/webcomponentsjs/'; 17 | // Feature detect which polyfill needs to be imported. 18 | var polyfills = []; 19 | if (!('attachShadow' in Element.prototype && 'getRootNode' in Element.prototype) || 20 | (window.ShadyDOM && window.ShadyDOM.force)) { 21 | polyfills.push('sd'); 22 | } 23 | if (!window.customElements || window.customElements.forcePolyfill) { 24 | polyfills.push('ce'); 25 | } 26 | // NOTE: any browser that does not have template or ES6 features 27 | // must load the full suite (called `lite` for legacy reasons) of polyfills. 28 | if (!('content' in document.createElement('template')) || !window.Promise || !Array.from || 29 | // Edge has broken fragment cloning which means you cannot clone template.content 30 | !(document.createDocumentFragment().cloneNode() instanceof DocumentFragment)) { 31 | polyfills = ['lite']; 32 | } 33 | 34 | if (polyfills.length) { 35 | var script = document.createElement('script'); 36 | // Load it from the right place. 37 | var name = 'webcomponents-' + polyfills.join('-') + '.js'; 38 | var url = path + name; 39 | script.src = url; 40 | // NOTE: this is required to ensure the polyfills are loaded before 41 | // *native* html imports load on older Chrome versions. This *is* CSP 42 | // compliant since CSP rules must have allowed this script to run. 43 | // In all other cases, this can be async. 44 | if (document.readyState === 'loading' && ('import' in document.createElement('link'))) { 45 | document.write(script.outerHTML); 46 | } else { 47 | document.head.appendChild(script); 48 | } 49 | } else { 50 | // Ensure `WebComponentsReady` is fired also when there are no polyfills loaded. 51 | // however, we have to wait for the document to be in 'interactive' state, 52 | // otherwise a rAF may fire before scripts in 53 | 54 | var fire = function() { 55 | window.WebComponents.ready = true; 56 | requestAnimationFrame(function() { 57 | document.dispatchEvent(new CustomEvent('WebComponentsReady', {bubbles: true})); 58 | }); 59 | }; 60 | 61 | if (document.readyState !== 'loading') { 62 | fire(); 63 | } else { 64 | document.addEventListener('readystatechange', function wait() { 65 | fire(); 66 | document.removeEventListener('readystatechange', wait); 67 | }); 68 | } 69 | } 70 | })(); 71 | -------------------------------------------------------------------------------- /src/store/auth/actions.ts: -------------------------------------------------------------------------------- 1 | import * as firebase from 'firebase/app'; 2 | 3 | export enum AuthTypes { 4 | SIGN_IN = 'USER_SIGN_IN', 5 | SIGN_OUT = 'USER_SIGN_OUT', 6 | SIGNED_IN = 'USER_SIGNED_IN', 7 | SIGNED_OUT = 'USER_SIGNED_OUT', 8 | } 9 | 10 | interface SignInAction { 11 | readonly type : AuthTypes.SIGN_IN; 12 | readonly payload : string; 13 | } 14 | 15 | interface SignOutAction { 16 | readonly type: AuthTypes.SIGN_OUT; 17 | } 18 | 19 | interface SignedInAction { 20 | readonly type : AuthTypes.SIGNED_IN; 21 | readonly payload : firebase.User; 22 | } 23 | 24 | interface SignedOutAction { 25 | readonly type: AuthTypes.SIGNED_OUT; 26 | } 27 | 28 | export type AuthActions = SignedInAction | SignedOutAction; 29 | 30 | export const signIn = (provider : string) : SignInAction => ({ 31 | type : AuthTypes.SIGN_IN, 32 | payload : provider, 33 | }) 34 | 35 | export const signOut = () : SignOutAction => ({ 36 | type : AuthTypes.SIGN_OUT, 37 | }) 38 | 39 | export const signedIn = (user : firebase.User) : SignedInAction => ({ 40 | type : AuthTypes.SIGNED_IN, 41 | payload : user, 42 | }) 43 | 44 | export const signedOut = () : SignedOutAction => ({ 45 | type : AuthTypes.SIGNED_OUT, 46 | }) 47 | -------------------------------------------------------------------------------- /src/store/auth/index.ts: -------------------------------------------------------------------------------- 1 | export * from './actions'; 2 | export * from './selectors'; 3 | -------------------------------------------------------------------------------- /src/store/auth/middleware.ts: -------------------------------------------------------------------------------- 1 | import * as firebase from 'firebase/app'; 2 | import { AuthTypes, signedIn, signedOut } from './actions'; 3 | 4 | export default ({ dispatch, getState }) => { 5 | 6 | const auth = firebase.auth(); 7 | 8 | auth.onAuthStateChanged(user => { 9 | if (user) { 10 | dispatch(signedIn(user)) 11 | 12 | } else { 13 | dispatch(signedOut()); 14 | } 15 | }); 16 | 17 | function providerFromName(name) { 18 | switch (name) { 19 | case 'facebook': return new firebase.auth.FacebookAuthProvider(); 20 | case 'google': return new firebase.auth.GoogleAuthProvider(); 21 | case 'twitter': return new firebase.auth.TwitterAuthProvider(); 22 | } 23 | } 24 | 25 | return next => action => { 26 | 27 | const { 28 | type, 29 | payload = {} 30 | } = action 31 | 32 | switch (type) { 33 | case AuthTypes.SIGN_IN: 34 | var provider = providerFromName(payload) 35 | auth.signInWithPopup(provider) 36 | break; 37 | 38 | case AuthTypes.SIGN_OUT: 39 | auth.signOut() 40 | break; 41 | 42 | default: 43 | return next(action) 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/store/auth/reducer.ts: -------------------------------------------------------------------------------- 1 | import * as firebase from 'firebase/app'; 2 | import { Reducer } from 'redux'; 3 | import { AuthActions, AuthTypes } from './actions'; 4 | 5 | export interface AuthState { 6 | user?: firebase.User; 7 | statusKnown: boolean; 8 | } 9 | 10 | const initialState : AuthState = { 11 | user: null, 12 | statusKnown: false, 13 | }; 14 | 15 | export default (state : AuthState = initialState, action: AuthActions) => { 16 | switch (action.type) { 17 | case AuthTypes.SIGNED_IN: 18 | return { ...state, 19 | user: action.payload, 20 | statusKnown: true, 21 | }; 22 | 23 | case AuthTypes.SIGNED_OUT: 24 | return { ...state, 25 | user: null, 26 | statusKnown: true, 27 | } 28 | 29 | default: 30 | return state; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/store/auth/selectors.ts: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect'; 2 | import { RootState } from '../reducer'; 3 | 4 | const getAuth = (state : RootState) => state.auth; 5 | 6 | export const user = createSelector( 7 | [getAuth], 8 | (auth) => auth.user 9 | ); 10 | 11 | export const statusKnown = createSelector( 12 | [getAuth], 13 | (auth) => auth.statusKnown 14 | ); 15 | 16 | export const anonymous = createSelector( 17 | [getAuth], 18 | (auth) => auth.user === null 19 | ) 20 | 21 | export const authenticated = createSelector( 22 | [getAuth], 23 | (auth) => auth.user !== null 24 | ) 25 | -------------------------------------------------------------------------------- /src/store/connect.ts: -------------------------------------------------------------------------------- 1 | import { Unsubscribe } from 'redux'; 2 | import store, { RootState } from './'; 3 | 4 | export interface Connectable { 5 | mapStateToProps(state : RootState) : any 6 | shouldUpdateProps?(state : RootState) : boolean 7 | } 8 | 9 | type Constructor = new(...args: any[]) => T; 10 | 11 | export function connect>(superclass: T) { 12 | class connected extends superclass { 13 | private unsubscribe : Unsubscribe; 14 | 15 | connectedCallback() { 16 | this.unsubscribe = store.subscribe(() => this.connect(store.getState())) 17 | this.connect(store.getState()) 18 | } 19 | 20 | disconnectedCallback() { 21 | this.unsubscribe() 22 | } 23 | 24 | shouldUpdateProps(state : RootState) : boolean { 25 | return true 26 | } 27 | 28 | connect(state : RootState) { 29 | if (this.shouldUpdateProps(state)) { 30 | this.setProperties(this.mapStateToProps(state)) 31 | } 32 | } 33 | 34 | setProperties(val : any) { 35 | Object.assign(this, val); 36 | } 37 | } 38 | 39 | return connected as T 40 | } 41 | -------------------------------------------------------------------------------- /src/store/firestore.ts: -------------------------------------------------------------------------------- 1 | import * as firebase from 'firebase/app'; 2 | import { MiddlewareAPI, Action } from 'redux'; 3 | import { RootState } from './reducer'; 4 | 5 | const firestore = firebase.firestore().enablePersistence() 6 | .then(() => firebase.firestore()) 7 | .catch((err) => { 8 | switch (err.code) { 9 | case 'failed-precondition': 10 | // multiple tabs open, persistence can only be enabled in one tab at a time 11 | case 'unimplemented': 12 | // the current browser does not support features required to enable persistence 13 | default: 14 | return firebase.firestore() 15 | } 16 | }) 17 | 18 | const subscriptions = {}; 19 | 20 | export type SubscribeFn = (db : firebase.firestore.Firestore) => Promise; 21 | export type SuccessFn = (doc : firebase.firestore.DocumentSnapshot) => Action; 22 | export type FailureFn = (err : Error) => Action; 23 | 24 | export default ({ dispatch, getState } : MiddlewareAPI) => next => action => { 25 | const { 26 | type, 27 | name, 28 | subscribe, 29 | unsubscribe, 30 | started, 31 | success, 32 | failure, 33 | payload = {} 34 | } : { 35 | type: string, 36 | name: string, 37 | subscribe: SubscribeFn, 38 | unsubscribe: string, 39 | started: any, // function => action ? 40 | success: SuccessFn, 41 | failure: FailureFn, 42 | payload: any, 43 | } = action; 44 | 45 | // TODO: define an interface for subscriptions to implement and check for that 46 | 47 | if (!subscribe && !unsubscribe) { 48 | return next(action) 49 | } 50 | 51 | if (subscribe) { 52 | dispatch(started) 53 | firestore.then(db => { 54 | subscriptions[name] = subscribe(db) 55 | .then(doc => dispatch(success(doc)), err => dispatch(failure(err))) 56 | }) 57 | return 58 | } 59 | 60 | if (unsubscribe) { 61 | subscriptions[unsubscribe]() 62 | return 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware, compose } from 'redux'; 2 | 3 | import root from './reducer'; 4 | import middleware from './middleware'; 5 | import { loadState } from './storage'; 6 | 7 | declare global { 8 | interface Window { __REDUX_DEVTOOLS_EXTENSION_COMPOSE__: any; } 9 | } 10 | 11 | const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; 12 | const enhancer = composeEnhancers(applyMiddleware(...middleware)); 13 | 14 | const state = loadState(); 15 | const store = createStore(root, state, enhancer); 16 | 17 | export default store; 18 | 19 | export { RootState } from './reducer'; 20 | export * from './auth'; 21 | -------------------------------------------------------------------------------- /src/store/middleware.ts: -------------------------------------------------------------------------------- 1 | import ReduxThunk from 'redux-thunk'; 2 | import auth from './auth/middleware'; 3 | import storage from './storage'; 4 | import firestore from './firestore'; 5 | import { historyMiddleware, routeMiddleware } from './router'; 6 | 7 | export default [ReduxThunk, auth, storage, firestore, historyMiddleware, routeMiddleware] 8 | -------------------------------------------------------------------------------- /src/store/reducer.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers, Reducer } from 'redux'; 2 | import auth, { AuthState } from './auth/reducer'; 3 | import { routerReducer as router, RouterState } from './router'; 4 | 5 | export interface RootState { 6 | readonly auth : AuthState; 7 | readonly router : RouterState; 8 | } 9 | 10 | export default combineReducers({ 11 | auth, 12 | router, 13 | }) 14 | -------------------------------------------------------------------------------- /src/store/router.ts: -------------------------------------------------------------------------------- 1 | import { createBrowserHistory, routerReducer, routerMiddleware, startListener, push } from 'redux-first-routing'; 2 | 3 | export interface RouterState { 4 | pathname: string 5 | search: string 6 | queries: any 7 | hash: string 8 | } 9 | 10 | const history = createBrowserHistory() 11 | const historyMiddleware = routerMiddleware(history) 12 | 13 | declare global { 14 | interface Event { 15 | readonly composed: boolean; 16 | readonly composedPath: () => Array; 17 | } 18 | } 19 | 20 | export const routeMiddleware = store => { 21 | startListener(history, store) 22 | 23 | window.document.documentElement.addEventListener('click', (e : MouseEvent) => { 24 | if ((e.button && e.button !== 0) 25 | || e.metaKey 26 | || e.altKey 27 | || e.ctrlKey 28 | || e.shiftKey 29 | || e.defaultPrevented === true) { 30 | return; 31 | } 32 | 33 | let origin = window.location.origin 34 | ? window.location.origin 35 | : window.location.protocol + '//' + window.location.host; 36 | 37 | let path = e.composedPath() 38 | for (let i = 0; i < path.length; i++) { 39 | let el = path[i] 40 | if (el instanceof HTMLAnchorElement) { 41 | let anchor = el; 42 | if (anchor.href.indexOf(origin) === 0) { 43 | e.preventDefault(); 44 | store.dispatch(push(anchor.href.substr(origin.length))) 45 | } 46 | return 47 | } 48 | } 49 | }) 50 | 51 | return next => action => next(action) 52 | } 53 | 54 | export { 55 | historyMiddleware, routerMiddleware, routerReducer, push 56 | } 57 | -------------------------------------------------------------------------------- /src/store/storage.ts: -------------------------------------------------------------------------------- 1 | import { MiddlewareAPI } from 'redux'; 2 | import { RootState } from './reducer'; 3 | 4 | const storageKey = 'app-state'; 5 | 6 | export const loadState = () : RootState => { 7 | try { 8 | const serializedState = localStorage.getItem(storageKey); 9 | if (serializedState === null) { 10 | return undefined; 11 | } 12 | return JSON.parse(serializedState); 13 | } catch(err) { 14 | console.error(err); 15 | return undefined; 16 | } 17 | } 18 | 19 | export default ({ getState } : MiddlewareAPI) => next => action => { 20 | const value = next(action) 21 | const state = getState(); 22 | 23 | try { 24 | const serializedState = JSON.stringify({ 25 | // only store whatever branch of the state should be persisted 26 | // selected: state.selected 27 | }); 28 | localStorage.setItem(storageKey, serializedState); 29 | } catch(err) { 30 | console.error(err); 31 | } 32 | 33 | return value 34 | } 35 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "sourceMap": true, 5 | "rootDir": "src", 6 | "module": "es2015", 7 | "target": "es2015", 8 | "moduleResolution": "node", 9 | "noImplicitAny": false, 10 | "lib": [ 11 | "es2017", 12 | "dom" 13 | ] 14 | } 15 | } 16 | --------------------------------------------------------------------------------