├── .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 |
--------------------------------------------------------------------------------