├── .babelrc ├── .editorconfig ├── .eslintrc.json ├── .gitignore ├── LICENSE ├── README.md ├── core ├── compile.js ├── database.js ├── globals.d.ts ├── globals.js ├── logger.js ├── onEnter.js ├── polyfills.js ├── request.js └── server.js ├── index.js ├── nodemon.json ├── package-lock.json ├── package.json ├── preview.png ├── server ├── config.js ├── middleware │ ├── authorize.js │ ├── catcher.js │ ├── context.js │ └── render.js ├── models │ ├── Account.js │ └── Todo.js ├── routes.js ├── routes │ ├── account.js │ └── todos.js └── server.js ├── src ├── assets │ ├── css │ │ ├── account.scss │ │ ├── base.scss │ │ ├── home.scss │ │ ├── index.scss │ │ ├── loading.scss │ │ └── menu.scss │ ├── favicon.ico │ ├── favicon.png │ ├── fonts │ │ └── fontawesome-webfont.woff │ ├── manifest.json │ └── service.js ├── components │ ├── Main.js │ ├── common │ │ ├── Error.js │ │ ├── Loading.js │ │ └── Menu.js │ └── home │ │ ├── AddTodo.js │ │ └── Todo.js ├── config │ ├── autorun.js │ ├── client.js │ ├── context.js │ ├── routes.js │ └── sw.js ├── pages │ ├── 404.js │ ├── About.js │ ├── Home.js │ ├── Login.js │ ├── Logout.js │ ├── Register.js │ └── index.html └── stores │ ├── Account.js │ ├── Common.js │ ├── State.js │ └── Todos.js ├── webpack.base.js ├── webpack.dev.js └── webpack.prod.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "add-module-exports", 4 | "transform-decorators-legacy", 5 | "transform-class-properties", 6 | "transform-es2015-modules-commonjs", 7 | "transform-es2015-destructuring", 8 | "transform-object-rest-spread", 9 | "inferno", 10 | ["babel-plugin-webpack-alias", {"config": "./webpack.base.js"}], 11 | ["fast-async"] 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | charset = utf-8 3 | 4 | [*] 5 | end_of_line = lf 6 | insert_final_newline = true 7 | 8 | [*.{js,jsx,scss}] 9 | indent_style = space 10 | indent_size = 2 11 | 12 | [{package.json,.travis.yml}] 13 | indent_style = space 14 | indent_size = 2 15 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": ["eslint:recommended", "plugin:inferno/recommended"], 4 | "plugins": [ 5 | "inferno" 6 | ], 7 | "settings": { 8 | "inferno": { 9 | "pragma": "Inferno" 10 | } 11 | }, 12 | "parserOptions": { 13 | "sourceType": "module", 14 | "ecmaVersion": 8, 15 | "ecmaFeatures": { 16 | "jsx": true, 17 | "experimentalObjectRestSpread": true 18 | } 19 | }, 20 | "env": { 21 | "browser": true, 22 | "jest": true, 23 | "node": true, 24 | "es6": true 25 | }, 26 | "globals": { 27 | "FB": true, 28 | "Exception": true, 29 | "Inferno": true, 30 | "Component": true, 31 | "isEmpty": true, 32 | "size": true 33 | }, 34 | "rules": { 35 | "semi": 0, 36 | "vars-on-top": 0, 37 | "spaced-comment": 0, 38 | "prefer-template": 0, 39 | "consistent-return": 0, 40 | "comma-dangle": 0, 41 | "no-use-before-define": 0, 42 | "no-return-assign": 0, 43 | "no-case-declarations": 0, 44 | "no-cond-assign": 0, 45 | "no-console": 0, 46 | "max-len": 0, 47 | "arrow-body-style": 0, 48 | "new-cap": 0, 49 | "quotes": 0, 50 | "quote-props": 0, 51 | "prefer-arrow-callback": 0, 52 | "func-names": 0, 53 | "padded-blocks": 0, 54 | "keyword-spacing": 0, 55 | "no-var": 1, 56 | "no-trailing-spaces": 0, 57 | "no-unused-expressions": 0, 58 | "no-unused-vars": 1, 59 | "no-inner-declarations": 0, 60 | "space-before-function-paren": 0, 61 | "global-require": 0, 62 | "inferno/jsx-key": 0, 63 | "inferno/no-unescaped-entities": 0, 64 | "react/jsx-no-bind": 0, 65 | "react/jsx-space-before-closing": 0, 66 | "react/jsx-closing-bracket-location": 0, 67 | "react/prop-types": 0, 68 | "react/prefer-stateless-function": 0 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # APP 2 | node_modules 3 | build/ 4 | data/ 5 | uploads/ 6 | stats.json 7 | 8 | # Other 9 | *.log 10 | *.log.* 11 | *.db 12 | .idea 13 | .DS_Store 14 | *.map 15 | .sass-cache/* 16 | .TemporaryItems/* 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Ryan Megidov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Inferno + Mobx Starter project 2 | 3 |

 

4 |

5 |

 

6 | 7 | The goal of this project is to provide a starting base for an mobx inferno project with isomorphism. 8 | 9 | Features: 10 | + `async/await` support 11 | + Isomorphic for SEO goodness 12 | + CSS and SCSS compilation 13 | + MongoDB user register/login/logout 14 | + Token based authentication 15 | + Decorators for accessing actions and state 16 | + Hot reload 17 | + Bundle size as small as possible 18 | + Offline support through service workers 19 | 20 | 21 | ![Preview](https://raw.githubusercontent.com/nightwolfz/inferno-starter/master/preview.png) 22 | 23 | ## How to run 24 | 25 | For development: 26 | 27 | npm run dev 28 | 29 | For production: 30 | 31 | npm run prod 32 | 33 | ## Requirements 34 | 35 | Node 6+ (or Node 4 with additional babel plugins) 36 | MongoDB server 37 | 38 | ## Goals 39 | 40 | - Optimized for minimal bundle size. 41 | - Optimized for server-side speed. (See the benchmark, it's fast !) 42 | - Using Inferno, the fastest React-like framework out there. 43 | - Using MobX, the easiest and insanely fast state manager. 44 | - Simple and minimal with routing, authentication, database and server-side rendering. 45 | - Good developer experience with hot-reloading and source-maps. 46 | - Get to 100% score on Google Lighthouse 47 | 48 | # Benchmarks 49 | 50 | ```sh 51 | gb -G=4 -k=true -c 200 -n 10000 http://localhost:2000/about 52 | 53 | Document Path: /about 54 | Document Length: 1436 bytes 55 | 56 | Concurrency Level: 200 57 | Time taken for tests: 3.06 seconds 58 | Complete requests: 10000 59 | Failed requests: 0 60 | HTML transferred: 14360000 bytes 61 | Requests per second: 3262.76 [#/sec] (mean) 62 | Time per request: 61.298 [ms] (mean) 63 | Time per request: 0.306 [ms] (mean, across all concurrent requests) 64 | HTML Transfer rate: 4575.37 [Kbytes/sec] received 65 | 66 | Connection Times (ms) 67 | min mean[+/-sd] median max 68 | Total: 1 0 12.07 59 246 69 | ``` 70 | Tested on i7-6700K @ 4.00GHz 16GB RAM. **Single** node.js instance. 71 | 72 | # F.A.Q. 73 | 74 | ## What are `stores` ? 75 | 76 | State contains the state of your application (ex: list of your todos, UI state etc). 77 | Stores contain the methods that mutate that state (ex: adding a todo, fetching data). 78 | Technically our State object is also a store, but we make the differentiation so that our logic is easier to follow by using the same principes as redux (one big state object). 79 | 80 | ## How to access our `state` and `stores` in our components ? 81 | 82 | ```js 83 | @connect(['state', 'store']) 84 | class MyComponent extends Component { 85 | componentDidMount() { 86 | const { state, store } = this.props 87 | store.account.doSomething(); 88 | } 89 | 90 | render({ state, store }) { 91 | return
{state.account.username}
92 | } 93 | } 94 | ``` 95 | 96 | ## What is `connect` ? 97 | 98 | The `@connect` decorator injects stores into your components. 99 | Additionally, it keeps your components up to date with any changes in your stores. 100 | 101 | _Example: If you display a `messageCount` from a `Messages` store and it gets updated, 102 | then all the visible components that display that `messageCount` will update themselves._ 103 | 104 | 105 | ## Does connecting many components make my app slower? 106 | 107 | **No**, it actually allows the rendering to be done more efficiently. So connect as many as you want ! 108 | 109 | 110 | ## Adding mongoose models 111 | 112 | 1. Goto `src/server/models` 113 | 2. Add `[Name].js` with your model in it 114 | 115 | ## Adding stores 116 | 117 | 1. Goto `src/config/stores` 118 | 2. Add `[Name].js` (it's just a class, ex: `Account.js`) 119 | 3. Update `src/config/stores.js` 120 | 121 | ## Enabling server-side rendering 122 | 123 | 1. Goto `server/config.js` 124 | 2. Change `SSR: false` to `SSR: true` 125 | 126 | ## My components are not updating! 127 | 128 | Make sure you added the `@connect` decorator to your component. 129 | 130 | ## My stateless component doesn't have access to the stores ! 131 | 132 | You cannot use decorators on stateless components. 133 | You should instead wrap your component like this: 134 | 135 | ```js 136 | // Simple observable component without injection 137 | const MyComponent = connect(props => { 138 | return

Something is cool

139 | }) 140 | 141 | // or with injection into props 142 | const MyComponent = connect(['state', 'store'])(props => { 143 | return

We have stores and state in our props: {props.state.something}

144 | }) 145 | ```` 146 | 147 | ## How do I execute async actions on the server and/or client ? 148 | 149 | Add a static `onEnter` method to your component like this: 150 | 151 | ```js 152 | class MyComponent extends Component { 153 | static onEnter({ myStore, params }) { 154 | return myStore.browse() 155 | } 156 | // ... 157 | } 158 | ``` 159 | 160 | The `onEnter` method is smart, it will be executed either on the server or on the browser depending on how you access the website. 161 | 162 | It also passes all your stores and url params as arguments as a convenience. 163 | 164 | 165 | ## Useful links 166 | 167 | [Inferno](https://github.com/trueadm/inferno) 168 | 169 | [MobX](https://mobxjs.github.io/mobx/) 170 | 171 | 172 | ## Author 173 | 174 | Ryan Megidov 175 | 176 | https://github.com/nightwolfz/inferno-starter 177 | -------------------------------------------------------------------------------- /core/compile.js: -------------------------------------------------------------------------------- 1 | import {fork} from 'child_process' 2 | import {debounce} from 'lodash' 3 | 4 | const dirs = ['./server/**/*.js'] 5 | const args = process.argv.slice(2) 6 | 7 | if (args.includes('--dev')) { 8 | process.env.NODE_ENV = 'development' 9 | let server = fork('./core/server.js') 10 | require('../webpack.dev.js') 11 | 12 | // Run server 13 | const chokidar = require('chokidar') 14 | const watcher = chokidar.watch(dirs) 15 | const restart = debounce(function() { 16 | server.kill() 17 | server.on('exit', function() { 18 | console.server('✓ SERVER RESTART') 19 | server = fork('./core/server.js') 20 | }) 21 | }, 100) 22 | 23 | watcher.on('ready', function() { 24 | watcher.on('all', restart) 25 | }) 26 | } 27 | 28 | if (args.includes('--prod') || process.env.NODE_ENV === 'production') { 29 | process.env.NODE_ENV = 'production' 30 | require('./server') 31 | fork('./webpack.prod.js') 32 | } 33 | -------------------------------------------------------------------------------- /core/database.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose' 2 | import config from '../server/config' 3 | 4 | // Use native promises 5 | mongoose.Promise = Promise 6 | 7 | // Initialize our database 8 | mongoose.connect(config.databases.mongo, { 9 | useMongoClient: true 10 | }) 11 | 12 | const db = mongoose.connection 13 | db.on('error', (err) => console.error(err)) 14 | db.once('open', () => console.info(config.databases.mongo)) 15 | 16 | export default db 17 | -------------------------------------------------------------------------------- /core/globals.d.ts: -------------------------------------------------------------------------------- 1 | declare function size(x: string | object | any[]): number; 2 | 3 | declare class Exception { 4 | constructor(message: string); 5 | } 6 | -------------------------------------------------------------------------------- /core/globals.js: -------------------------------------------------------------------------------- 1 | import { isObservableArray } from 'mobx' 2 | 3 | global.Exception = class Exception extends Error { 4 | constructor(message) { 5 | super(message); 6 | this.message = message; 7 | this.name = 'Exception'; 8 | } 9 | } 10 | 11 | global.size = function size(input) { 12 | if (isObservableArray(input) || typeof input === 'string') { 13 | return input.length 14 | } 15 | return input && ((typeof input === 'string') ? input.length : Object.keys(input).length) 16 | } 17 | -------------------------------------------------------------------------------- /core/logger.js: -------------------------------------------------------------------------------- 1 | if (process.env.BROWSER) { 2 | console.debug = console.log.bind(console, '%cDBG', 'color: #00893f; font-weight: bold') 3 | console.info = console.log.bind(console, '%cINF', 'color: #007bff; font-weight: bold') 4 | } else { 5 | const {inspect} = require('util') 6 | function logger(color, name) { 7 | return function() { 8 | // Get arguments without deoptimizing v8 9 | const args = [] 10 | for (let i = 0; i < arguments.length; i++) { 11 | if (typeof arguments[i] === 'object') { 12 | args.push(inspect(arguments[i], { 13 | colors: true, 14 | depth: 4, 15 | breakLength: Infinity 16 | })) 17 | } else { 18 | args.push(arguments[i]) 19 | } 20 | } 21 | console.log('\u001b[3' + color + ';1m' + name + '\u001b[0m', ...args) 22 | } 23 | } 24 | 25 | // Enable color logging 26 | console.debug = logger(6, 'DBG').bind(console) 27 | console.info = logger(2, 'INF').bind(console) 28 | console.warn = logger(3, 'WRN').bind(console) 29 | console.error = logger(1, 'ERR').bind(console) 30 | console.server = (msg) => console.log('\u001b[34;1m' + msg + '\u001b[0m') 31 | } 32 | -------------------------------------------------------------------------------- /core/onEnter.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Go through the mached route and extract the static method 3 | * @param staticMethod {String} 4 | * @param components {Object} 5 | * @param promises {Array} 6 | * @returns {Object} 7 | */ 8 | function getRoutes(staticMethod, components, promises) { 9 | const routes = Array.isArray(components) ? components : [components] 10 | 11 | routes.forEach(({ props }) => { 12 | props.component[staticMethod] && promises.push(props.component[staticMethod]) 13 | props.children && getRoutes(staticMethod, props.children, promises) 14 | }) 15 | return routes[0].props.params 16 | } 17 | 18 | /** 19 | * Execute onEnter methods of matched components 20 | * @returns {Promise} 21 | */ 22 | export default (routes, stores) => { 23 | const promises = [] 24 | const params = getRoutes('onEnter', routes.matched, promises) 25 | 26 | return Promise.all(promises.map(onEnter => onEnter({ 27 | ...stores, 28 | params 29 | }))) 30 | } 31 | -------------------------------------------------------------------------------- /core/polyfills.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // For IE 11 4 | if (typeof Promise === 'undefined') { 5 | global.Promise = require('promise-polyfill') 6 | } 7 | 8 | /** 9 | * Returns a numeric hash of a string 10 | * @returns {number} 11 | */ 12 | String.prototype.hashCode = function() { 13 | var hash = 0, i, chr, len; 14 | if (this.length === 0) return hash; 15 | for (i = 0, len = this.length; i < len; i++) { 16 | chr = this.charCodeAt(i); 17 | hash = ((hash << 5) - hash) + chr; 18 | hash |= 0; // Convert to 32bit integer 19 | } 20 | return hash 21 | } 22 | 23 | /** 24 | * Here we add a few ES6 polyfills since we dont want to include whole of babel-polyfill 25 | */ 26 | if (!String.prototype.startsWith) { 27 | String.prototype.startsWith = function(searchString, position){ 28 | position = position || 0; 29 | return this.substr(position, searchString.length) === searchString; 30 | }; 31 | } 32 | 33 | if (!String.prototype.includes) { 34 | String.prototype.includes = function includes(str) { 35 | return this.indexOf(str) !== -1; 36 | }; 37 | } 38 | if (!String.prototype.trimLeft) { 39 | String.prototype.trimLeft = function trimLeft(str) { 40 | return remove(this, `^${str || '\\s'}+`); 41 | }; 42 | } 43 | if (!String.prototype.trimRight) { 44 | String.prototype.trimRight = function trimRight(str) { 45 | return remove(this, `${str || '\\s'}+$`); 46 | }; 47 | } 48 | 49 | function remove(str, rx) { 50 | return str.replace(new RegExp(rx), ''); 51 | } 52 | -------------------------------------------------------------------------------- /core/request.js: -------------------------------------------------------------------------------- 1 | import fetch from 'isomorphic-fetch' 2 | import config from '../server/config' 3 | 4 | /** 5 | * This is our overly complicated isomorphic "request" 6 | * @param state 7 | * @returns {Function} 8 | */ 9 | export default function(token) { 10 | return { 11 | get(url, params) { 12 | return buildRequest('GET', token, url, omitNil(params)) 13 | }, 14 | 15 | post(url, data) { 16 | return buildRequest('POST', token, url, data, false) 17 | }, 18 | 19 | upload(url, data) { 20 | return buildRequest('POST', token, url, data, true) 21 | }, 22 | } 23 | } 24 | 25 | /** 26 | * Build and execute remote request 27 | * @param method 28 | * @param url 29 | * @param params 30 | * @param config 31 | */ 32 | function buildRequest(method, token, url, params, isMultiForm) { 33 | const requestURL = createURL(url) + (method === 'GET' && params ? toQueryString(params) : '') 34 | const request = { 35 | method, 36 | mode: 'cors', 37 | credentials: 'include', 38 | headers: { 39 | token 40 | } 41 | } 42 | 43 | if (!isMultiForm) { 44 | request.headers['Content-Type'] = 'application/json' 45 | } 46 | 47 | if (method === 'POST') { 48 | if (isMultiForm) { 49 | const formData = new FormData() 50 | for(var name in params) { 51 | formData.append(name, params[name]); 52 | } 53 | request.body = formData 54 | } else { 55 | request.body = JSON.stringify(params || {}) 56 | } 57 | } 58 | 59 | return fetch(requestURL, request).then(handleResponse) 60 | } 61 | 62 | /** 63 | * Prepend host of API server 64 | * @param path 65 | * @returns {String} 66 | * @private 67 | */ 68 | function createURL(path) { 69 | if (path.startsWith('http')) { 70 | return path 71 | } else if (process.env.BROWSER) { 72 | return '/' + path.trimLeft('/') 73 | } else { 74 | return `http://${global.HOSTNAME}:${global.PORT}/` + path.trimLeft('/') 75 | } 76 | } 77 | 78 | /** 79 | * Decide what to do with the response 80 | * @param response 81 | * @returns {Promise} 82 | * @private 83 | */ 84 | function handleResponse(response) { 85 | const redirect = response.headers.get('Location') 86 | if (process.env.BROWSER && redirect) { 87 | window.location.replace(redirect) 88 | return Promise.reject() 89 | } 90 | 91 | if (response.headers.get('content-type').includes('json')) { 92 | return response.json().then(res => { 93 | if (response.ok) { 94 | if (response.status === 403) { 95 | console.warn('Unauthorized', response) 96 | } 97 | return res 98 | } else { 99 | throw res 100 | } 101 | }) 102 | } 103 | return response.text().then(error => { throw error }) 104 | } 105 | 106 | /** 107 | * Transform an JSON object to a query string 108 | * @param params 109 | * @returns {string} 110 | */ 111 | function toQueryString(params) { 112 | return '?' + Object.keys(params).map(k => { 113 | const name = encodeURIComponent(k) 114 | if (Array.isArray(params[k])) { 115 | return params[k].map(val => `${name}[]=${encodeURIComponent(val)}`).join('&') 116 | } 117 | return `${name}=${encodeURIComponent(params[k])}` 118 | }).join('&') 119 | } 120 | 121 | function omitNil(obj) { 122 | if (typeof obj !== 'object') return obj 123 | return Object.keys(obj).reduce((acc, v) => { 124 | if (obj[v] !== undefined) acc[v] = obj[v] 125 | return acc 126 | }, {}) 127 | } 128 | -------------------------------------------------------------------------------- /core/server.js: -------------------------------------------------------------------------------- 1 | // Enable ES2018 support 2 | require('babel-register') 3 | 4 | // Bootstrap core 5 | require('./logger') 6 | require('./polyfills') 7 | require('./globals') 8 | 9 | // Ignore files on server render 10 | require.extensions['.scss'] = function() { 11 | return 12 | } 13 | 14 | require('../server/server') 15 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | // Enable ES2018 support 2 | require('babel-register') 3 | 4 | // Launch server and webpack 5 | require('./core/logger') 6 | require('./core/compile') 7 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "verbose": false, 3 | "restartable": "rs", 4 | "ext": "js", 5 | "ignore": [ 6 | ".git", 7 | "node_modules", 8 | "build", 9 | "src/**/*" 10 | ], 11 | "watch": [ 12 | "core", 13 | "src/client/stores.js", 14 | "src/client/preload.js", 15 | "server" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "inferno-starter", 3 | "main": "index.js", 4 | "version": "0.6.0", 5 | "author": "Ryan Megidov ", 6 | "description": "Mobx + Inferno + Inferno-router + Webpack hot-reload starter kit", 7 | "license": "MIT", 8 | "private": true, 9 | "scripts": { 10 | "dev": "node index.js --dev", 11 | "prod": "node index.js --prod" 12 | }, 13 | "dependencies": { 14 | "babel-loader": "^7.1.2", 15 | "babel-plugin-add-module-exports": "0.2.1", 16 | "babel-plugin-inferno": "3.2.0", 17 | "babel-plugin-transform-async-to-generator": "6.24.1", 18 | "babel-plugin-transform-class-properties": "6.24.1", 19 | "babel-plugin-transform-decorators-legacy": "1.3.4", 20 | "babel-plugin-transform-es2015-arrow-functions": "6.22.0", 21 | "babel-plugin-transform-es2015-block-scoped-functions": "6.22.0", 22 | "babel-plugin-transform-es2015-block-scoping": "^6.26.0", 23 | "babel-plugin-transform-es2015-classes": "6.24.1", 24 | "babel-plugin-transform-es2015-computed-properties": "6.24.1", 25 | "babel-plugin-transform-es2015-destructuring": "6.23.0", 26 | "babel-plugin-transform-es2015-literals": "6.22.0", 27 | "babel-plugin-transform-es2015-modules-commonjs": "^6.26.0", 28 | "babel-plugin-transform-es2015-parameters": "6.24.1", 29 | "babel-plugin-transform-es2015-shorthand-properties": "6.24.1", 30 | "babel-plugin-transform-es2015-spread": "6.22.0", 31 | "babel-plugin-transform-es2015-template-literals": "6.22.0", 32 | "babel-plugin-transform-object-rest-spread": "^6.26.0", 33 | "babel-plugin-webpack-alias": "^2.1.2", 34 | "babel-register": "^6.26.0", 35 | "babili-webpack-plugin": "^0.1.2", 36 | "chokidar": "^1.7.0", 37 | "classnames": "^2.2.5", 38 | "css-loader": "^0.28.7", 39 | "debug": "^3.0.1", 40 | "extract-text-webpack-plugin": "^3.0.0", 41 | "fast-async": "^6.3.0", 42 | "file-loader": "0.11.2", 43 | "fs-extra-promise": "^1.0.1", 44 | "history": "^4.7.2", 45 | "inferno": "^3.9.0", 46 | "inferno-component": "^3.9.0", 47 | "inferno-create-class": "^3.9.0", 48 | "inferno-create-element": "^3.9.0", 49 | "inferno-mobx": "^3.9.0", 50 | "inferno-router": "^3.9.0", 51 | "inferno-server": "^3.9.0", 52 | "isomorphic-fetch": "2.2.1", 53 | "json-loader": "^0.5.7", 54 | "jwt-simple": "^0.5.1", 55 | "koa": "^2.3.0", 56 | "koa-better-body": "^3.0.4", 57 | "koa-compress": "^2.0.0", 58 | "koa-convert": "^1.2.0", 59 | "koa-favicon": "^2.0.0", 60 | "koa-mount": "^3.0.0", 61 | "koa-router": "^7.2.1", 62 | "koa-static": "^4.0.1", 63 | "lodash": "^4.17.4", 64 | "mobx": "^3.2.2", 65 | "mongoose": "^4.11.12", 66 | "node-sass": "4.5.3", 67 | "promise-polyfill": "6.0.2", 68 | "sass-loader": "6.0.6", 69 | "serve-favicon": "2.4.3", 70 | "style-loader": "0.18.2", 71 | "webpack": "^3.6.0" 72 | }, 73 | "devDependencies": { 74 | "babel-eslint": "^7.2.3", 75 | "cross-env": "5.0.5", 76 | "eslint": "^4.6.1", 77 | "eslint-plugin-inferno": "^7.0.1", 78 | "inferno-devtools": "3.9.0", 79 | "jsonfile": "^3.0.1", 80 | "mobx-logger": "^0.6.0", 81 | "nodemon": "1.12.0", 82 | "sw-precache-webpack-plugin": "^0.11.4", 83 | "webpack-dev-server": "^2.8.2", 84 | "webpack-manifest-plugin": "^1.3.1" 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nightwolfz/inferno-starter/15cc8163c87f7d30b378b432c02fd3f4ee3114cb/preview.png -------------------------------------------------------------------------------- /server/config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const isProduction = process.env.NODE_ENV === 'production' 3 | const root = (dir) => path.join(__dirname, '..', dir) 4 | 5 | // We need these globals to fetch data on server-side 6 | global.HOSTNAME = 'localhost' 7 | global.PORT = 2000 8 | 9 | module.exports = { 10 | http: { 11 | port: global.PORT, 12 | hostname: global.HOSTNAME, 13 | favicon: root('src/assets/favicon.ico'), 14 | static: { 15 | '/build': root('build'), 16 | '/': root('src/assets') 17 | } 18 | }, 19 | server: { 20 | DEV: !isProduction, 21 | SSR: false 22 | }, 23 | session: { 24 | secret: 'INFERNAL_SECRET_KEY_KERE', 25 | expires: 2 * 3600 * 1000 // 2 hours 26 | }, 27 | databases: { 28 | mongo: 'mongodb://127.0.0.1:27017/inferno-starter' 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /server/middleware/authorize.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Middleware for checking if we're logged in 3 | * @param ctx 4 | * @param next 5 | */ 6 | export default async(ctx, next) => { 7 | 8 | if (ctx.account && !ctx.token) { 9 | ctx.redirect('/page/login') 10 | ctx.status = 401 11 | throw new Exception('Token is invalid: ' + ctx.token) 12 | } 13 | 14 | ctx.authorized = ctx.account && ctx.account.id && ctx.token 15 | await next() 16 | } 17 | -------------------------------------------------------------------------------- /server/middleware/catcher.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Middleware for catching errors thrown in routes 3 | * @param ctx 4 | */ 5 | export default async function(err) { 6 | if (!err) return 7 | 8 | let status = err.status || 400 9 | 10 | const type = this.accepts(['text', 'json', 'html']) 11 | if (!type) { 12 | status = 406; 13 | err.message = 'Unsupported type' 14 | } 15 | 16 | if (!(parseInt(status) > 0)) { 17 | status = 500 18 | } 19 | 20 | this.body = err.message 21 | 22 | // Exceptions are generated by our app (ex: Wrong login) 23 | // So we don't need to log the whole stacktrace 24 | if (err.constructor.name !== 'Exception') { 25 | this.app.emit('error', err, this) 26 | } 27 | 28 | // Nothing we can do here other than delegate to the app-level handler and log. 29 | if (this.headerSent || !this.writable) { 30 | console.debug('headers were already sent, returning early') 31 | err.headerSent = true 32 | return 33 | } 34 | 35 | this.type = 'json' 36 | this.status = status 37 | this.body = JSON.stringify(this.body, null, 2) 38 | this.length = Buffer.byteLength(this.body) 39 | this.res.end(this.body) 40 | } 41 | -------------------------------------------------------------------------------- /server/middleware/context.js: -------------------------------------------------------------------------------- 1 | import { useStaticRendering } from 'inferno-mobx' 2 | import { toJS } from 'mobx' 3 | import Account from '../models/Account' 4 | import State from '../../src/stores/State' 5 | import context from '../../src/config/context' 6 | 7 | useStaticRendering(true) 8 | 9 | const stateClone = JSON.stringify(toJS(new State({}))) 10 | 11 | /** 12 | * Middleware for creating the context 13 | * @param ctx 14 | * @param next 15 | */ 16 | export default async(ctx, next) => { 17 | // Get our token from headers (server) or cookies (client) 18 | ctx.token = ctx.headers['token'] || ctx.cookies.get('token') 19 | 20 | // Check if logged in 21 | ctx.account = await Account.getAccount(ctx.token) 22 | 23 | // Create state for SSR 24 | const state = JSON.parse(stateClone) 25 | 26 | if (ctx.account.id) { 27 | state.account = ctx.account 28 | } 29 | 30 | ctx.context = context(state) 31 | 32 | await next() 33 | } 34 | -------------------------------------------------------------------------------- /server/middleware/render.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import { resolve } from 'path' 3 | import Inferno from 'inferno' 4 | import { renderToStaticMarkup } from 'inferno-server' 5 | import { RouterContext, match } from 'inferno-router' 6 | import { Provider } from 'inferno-mobx' 7 | import onEnter from 'core/onEnter' 8 | import Main from '../../src/components/Main' 9 | import config from '../config' 10 | import routes from '../../src/config/routes' 11 | 12 | const indexHTML = fs.readFileSync(resolve(__dirname, '../../src/pages/index.html'), 'utf8') 13 | 14 | // Server-side render 15 | export default async(ctx) => { 16 | 17 | const renderProps = match(routes, ctx.url) 18 | const bundleURL = config.server.DEV ? `//localhost:2002` : '' 19 | 20 | if (renderProps.redirect) { 21 | return ctx.redirect(renderProps.redirect) 22 | } 23 | 24 | if (config.server.SSR) { 25 | try { 26 | await onEnter(renderProps, ctx.context) 27 | } catch(error) { 28 | throw error 29 | } 30 | } 31 | 32 | const components = config.server.SSR ? renderToStaticMarkup( 33 | 34 | 38 | 39 | ) : '' 40 | 41 | ctx.body = indexHTML 42 | .replace(/{bundleURL}/g, bundleURL) 43 | .replace('{title}', ctx.context.state.common.title) 44 | .replace('{state}', JSON.stringify(ctx.context.state, null, 2)) 45 | .replace('{children}', components) 46 | } 47 | -------------------------------------------------------------------------------- /server/models/Account.js: -------------------------------------------------------------------------------- 1 | import database from 'core/database' 2 | import { Schema } from 'mongoose' 3 | 4 | const schema = new Schema({ 5 | username: { 6 | type: String, 7 | required: true, 8 | unique: true, 9 | index: true, 10 | trim: true 11 | }, 12 | password: { 13 | type: String, 14 | required: true, 15 | select: false 16 | }, 17 | token: { 18 | type: String, 19 | select: false 20 | }, 21 | description: { type: String } 22 | }) 23 | 24 | // Options 25 | schema.set('toJSON', { 26 | getters: true, 27 | transform(doc, ret) { 28 | ret.id = ret._id 29 | delete ret._id 30 | } 31 | }) 32 | 33 | /** 34 | * Get account by token 35 | * @param token {string} 36 | * @returns {object} 37 | */ 38 | schema.statics.getAccount = async function(token) { 39 | if (token) { 40 | const account = await this.findOne({ token }, '+token').populate('profile') 41 | return account || {} 42 | } 43 | return {} 44 | } 45 | 46 | export default database.model('Account', schema) 47 | -------------------------------------------------------------------------------- /server/models/Todo.js: -------------------------------------------------------------------------------- 1 | import database from 'core/database' 2 | import { Schema } from 'mongoose' 3 | 4 | const schema = new Schema({ 5 | text: String, 6 | image: String, 7 | createdBy: { 8 | type: Schema.Types.ObjectId, 9 | ref: 'Account' 10 | } 11 | }) 12 | 13 | // Options 14 | schema.set('toJSON', { 15 | transform(doc, ret) { 16 | ret.id = ret._id 17 | delete ret._id 18 | } 19 | }) 20 | 21 | export default database.model('Todo', schema) 22 | -------------------------------------------------------------------------------- /server/routes.js: -------------------------------------------------------------------------------- 1 | import Router from 'koa-router' 2 | import authorize from './middleware/authorize' 3 | import * as account from './routes/account' 4 | import * as todos from './routes/todos' 5 | 6 | const router = new Router() 7 | 8 | router.get('/api/todos', todos.getTodos) 9 | router.post('/api/todos/add', authorize, todos.addTodos) 10 | router.post('/api/todos/remove', authorize, todos.removeTodos) 11 | router.get('/api/account/logout', account.logout) 12 | router.post('/api/account/login', account.login) 13 | router.post('/api/account/register', account.register) 14 | 15 | export default router 16 | -------------------------------------------------------------------------------- /server/routes/account.js: -------------------------------------------------------------------------------- 1 | import jwt from 'jwt-simple' 2 | import crypto from 'crypto' 3 | import config from '../config' 4 | import Account from '../models/Account' 5 | 6 | export async function login(ctx) { 7 | const { username, password } = ctx.request.fields 8 | const account = await Account.findOne({ 9 | username, 10 | password: sha512(password, { salt: username }) 11 | }) 12 | if (!account) throw new Exception('Wrong credentials') 13 | 14 | account.token = createAuthToken(account._id) 15 | await account.save() 16 | 17 | ctx.cookies.set('token', account.token) 18 | ctx.body = account.toJSON() 19 | } 20 | 21 | export async function logout(ctx) { 22 | // Clear in db 23 | await Account.findOneAndUpdate({ token: ctx.token }, { token: null }) 24 | 25 | ctx.cookies.set('token', null) 26 | ctx.body = {} 27 | } 28 | 29 | export async function register(ctx) { 30 | const { username, password, email } = ctx.request.fields 31 | 32 | if (!isValidUsername(username)) { 33 | throw new Exception('Username cannot contain special characters') 34 | } 35 | const exists = await Account.count({ username }) 36 | if (exists) throw new Exception('Username already taken') 37 | 38 | const account = new Account({ 39 | username, 40 | password: sha512(password, { salt: username }), 41 | email 42 | }) 43 | account.token = createAuthToken(account._id) 44 | await account.save() 45 | 46 | ctx.cookies.set('token', account.token) 47 | ctx.body = account 48 | } 49 | 50 | /** 51 | * Check if we're logged in 52 | * @param token {string} 53 | * @returns {boolean} 54 | */ 55 | export async function checkAuthorized(ctx) { 56 | ctx.authorized = false 57 | if (!ctx.token) throw new Exception('Token not provided') 58 | 59 | const account = await Account.findOne({ token: ctx.token }, 'token') 60 | if (!account) throw new Exception('Invalid token') 61 | 62 | const decoded = jwt.decode(account.token, config.session.secret) 63 | if (Date.now() < decoded.expires) { 64 | ctx.authorized = true 65 | } else { 66 | ctx.cookies.set('token', null) 67 | throw new Exception('Token expired: ' + new Date(decoded.expires)) 68 | } 69 | } 70 | 71 | /** 72 | * Create a new token with a timestamp 73 | * @private 74 | * @param accountID 75 | * @returns {string|*} 76 | */ 77 | function createAuthToken(accountID) { 78 | const payload = { 79 | accountID, 80 | expires: Date.now() + config.session.expires 81 | } 82 | return jwt.encode(payload, config.session.secret) 83 | } 84 | 85 | /** 86 | * Hash the password 87 | * @private 88 | * @param str {string} 89 | * @param options {object} 90 | * @returns {string} 91 | */ 92 | function sha512(str, options) { 93 | return crypto.createHmac('sha512', options.salt).update(str).digest('hex') 94 | } 95 | 96 | /** 97 | * Check for special characters 98 | * @param username 99 | * @returns {boolean} 100 | */ 101 | function isValidUsername(username) { 102 | return /^[a-z0-9_-]+$/i.test(username) 103 | } 104 | -------------------------------------------------------------------------------- /server/routes/todos.js: -------------------------------------------------------------------------------- 1 | import Todo from '../models/Todo' 2 | 3 | export async function getTodos(ctx) { 4 | 5 | if (!ctx.account.id) { 6 | ctx.body = [] 7 | return 8 | } 9 | 10 | const response = await Todo.find({ 11 | createdBy: ctx.account 12 | }).limit(50).exec() 13 | 14 | ctx.body = response 15 | } 16 | 17 | export async function addTodos(ctx) { 18 | const { fields } = ctx.request 19 | 20 | if (!fields.text) throw new Exception('[text] not provided') 21 | 22 | const newTodo = new Todo({ 23 | text: fields.text, 24 | createdBy: ctx.account 25 | }) 26 | const response = await newTodo.save() 27 | 28 | ctx.body = response 29 | } 30 | 31 | export async function removeTodos(ctx) { 32 | const { fields } = ctx.request 33 | 34 | if (!fields.id) throw new Exception('[id] not provided') 35 | 36 | const response = await Todo.remove({ _id: fields.id }) 37 | 38 | ctx.body = response ? { success: true } : { success: false } 39 | } 40 | -------------------------------------------------------------------------------- /server/server.js: -------------------------------------------------------------------------------- 1 | import Koa from 'koa' 2 | import bodyParser from 'koa-better-body' 3 | import favicon from 'koa-favicon' 4 | import convert from 'koa-convert' 5 | import config from './config' 6 | import context from './middleware/context' 7 | import catcher from './middleware/catcher' 8 | import render from './middleware/render' 9 | import routes from './routes' 10 | 11 | const app = new Koa() 12 | 13 | // override koa's undocumented error handler 14 | app.context.onerror = catcher 15 | 16 | // Serve static files 17 | const mount = require('koa-mount') 18 | const serve = require('koa-static') 19 | 20 | for(const [k, v] of Object.entries(config.http.static)) { 21 | app.use(mount(k, serve(v, {index: false}))) 22 | } 23 | 24 | // Middleware 25 | app.use(favicon(config.http.favicon)) 26 | app.use(convert(bodyParser({ 27 | formLimit: '200kb', 28 | jsonLimit: '200kb', 29 | bufferLimit: '4mb' 30 | }))) 31 | app.use(context) 32 | 33 | // Routes 34 | app.use(routes.routes()) 35 | app.use(render) 36 | 37 | app.listen(config.http.port, function() { 38 | console.info('Listening on port ' + config.http.port) 39 | }) 40 | -------------------------------------------------------------------------------- /src/assets/css/account.scss: -------------------------------------------------------------------------------- 1 | .account { 2 | background: #fff; 3 | padding: 0.4rem; 4 | margin: 1rem 0 0 0; 5 | box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), 0 25px 50px 0 rgba(0, 0, 0, 0.1); 6 | 7 | label { 8 | padding: 0.3rem 1rem; 9 | line-height: 2rem; 10 | } 11 | 12 | input { 13 | padding: 0.4rem 0.4rem; 14 | margin-left: 0.8rem; 15 | line-height: 1rem; 16 | } 17 | 18 | button { 19 | padding: 0.3rem 1rem; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/assets/css/base.scss: -------------------------------------------------------------------------------- 1 | @charset "UTF-8"; 2 | 3 | div, span, object, iframe, blockquote, pre, a, abbr, cite, code, em, font, img, q, s, samp, small, strike, strong, sub, sup, b, u, i, center, ul, li, fieldset, form, label, legend, table, caption, tbody, tr, th, td { 4 | border: 0; 5 | margin: 0; 6 | padding: 0; 7 | vertical-align: baseline; 8 | } 9 | 10 | html { 11 | height: 100%; 12 | } 13 | 14 | body { 15 | height: 100%; 16 | background: #F4F5F7; 17 | font-family: "Open Sans","lucida grande","Segoe UI",tahoma,sans-serif; 18 | font-size: 13px; 19 | font-weight: normal; 20 | color: #3d464d; 21 | padding: 0; 22 | margin: 0; 23 | border: 0; 24 | overflow-x: hidden; 25 | letter-spacing: 0.2px; 26 | } 27 | 28 | hr { 29 | margin: 1em 0; 30 | overflow: hidden; 31 | background: transparent; 32 | border: 0; 33 | border-bottom: 1px solid #985527; 34 | animation: border-colors 20s ease-in-out infinite; 35 | } 36 | 37 | img { 38 | user-select: none; 39 | -moz-user-select: none; 40 | -webkit-user-drag: none; 41 | -webkit-user-select: none; 42 | -ms-user-select: none; 43 | } 44 | 45 | li { 46 | list-style: none; 47 | } 48 | 49 | table { 50 | border-collapse: collapse; 51 | border-spacing: 0; 52 | td, th { 53 | padding: 0; 54 | } 55 | } 56 | 57 | input::-webkit-input-placeholder { 58 | font-style: italic; 59 | font-weight: 300; 60 | color: #bbb; 61 | } 62 | 63 | input::-moz-placeholder { 64 | font-style: italic; 65 | font-weight: 300; 66 | color: #bbb; 67 | } 68 | 69 | //noinspection CssInvalidPseudoSelector 70 | input::input-placeholder { 71 | font-style: italic; 72 | font-weight: 300; 73 | color: #bbb; 74 | } 75 | -------------------------------------------------------------------------------- /src/assets/css/home.scss: -------------------------------------------------------------------------------- 1 | // Typography 2 | main { 3 | width: 680px; 4 | margin: 0 auto; 5 | } 6 | 7 | h1 { 8 | font-size: 3rem; 9 | font-weight: 100; 10 | text-align: center; 11 | margin: 0; 12 | } 13 | 14 | .error-message { 15 | color: #f66; 16 | margin: 2px 0 4px 0; 17 | } 18 | 19 | .hidden { 20 | display: none; 21 | } 22 | 23 | .home { 24 | background: #fff; 25 | position: relative; 26 | box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), 27 | 0 25px 50px 0 rgba(0, 0, 0, 0.1); 28 | } 29 | 30 | .new-todo { 31 | position: relative; 32 | margin: 0; 33 | width: 100%; 34 | font-size: 24px; 35 | font-family: inherit; 36 | font-weight: inherit; 37 | line-height: 1.4em; 38 | outline: none; 39 | color: inherit; 40 | box-sizing: border-box; 41 | padding: 16px 16px 16px 60px; 42 | border: none; 43 | background: rgba(0, 0, 0, 0.003); 44 | box-shadow: inset 0 -2px 1px rgba(0,0,0,0.03); 45 | } 46 | 47 | .todo-list { 48 | margin: 0; 49 | padding: 0; 50 | list-style: none; 51 | } 52 | 53 | .todo-list li { 54 | position: relative; 55 | font-size: 24px; 56 | border-bottom: 1px solid #ededed; 57 | } 58 | 59 | .todo-list li:last-child { 60 | border-bottom: none; 61 | } 62 | 63 | .todo-list li label { 64 | white-space: pre-line; 65 | word-break: break-all; 66 | padding: 15px 60px 15px 15px; 67 | margin-left: 45px; 68 | display: block; 69 | line-height: 1.2; 70 | transition: color 0.4s; 71 | } 72 | 73 | .todo-list li .destroy { 74 | display: none; 75 | position: absolute; 76 | top: 0; 77 | right: 10px; 78 | bottom: 0; 79 | width: 40px; 80 | height: 40px; 81 | font-size: 30px; 82 | color: #cc9a9a; 83 | margin: auto 0 11px; 84 | border: none; 85 | cursor: pointer; 86 | background: transparent; 87 | transition: color 0.2s ease-out; 88 | } 89 | 90 | .todo-list li .destroy:hover { 91 | color: #af5b5e; 92 | } 93 | 94 | .todo-list li .destroy:after { 95 | content: '×'; 96 | } 97 | 98 | .todo-list li:hover .destroy { 99 | display: block; 100 | } 101 | 102 | -------------------------------------------------------------------------------- /src/assets/css/index.scss: -------------------------------------------------------------------------------- 1 | // Make sure to add extension to the filenames here 2 | @import "loading.scss"; 3 | @import "account.scss"; 4 | @import "home.scss"; 5 | @import "menu.scss"; 6 | @import "base.scss"; 7 | -------------------------------------------------------------------------------- /src/assets/css/loading.scss: -------------------------------------------------------------------------------- 1 | $offset: 187; 2 | $duration: 1.4s; 3 | 4 | .spinner-wrapper { 5 | position: absolute; 6 | text-align: center; 7 | margin-top: 10%; 8 | width: 100%; 9 | pointer-events: none; 10 | -webkit-user-select: none; 11 | h1 { 12 | margin-top: 1em; 13 | } 14 | .spinner { 15 | animation: rotator $duration linear infinite; 16 | } 17 | 18 | .path { 19 | stroke-dasharray: $offset; 20 | stroke-dashoffset: 0; 21 | transform-origin: center; 22 | animation: 23 | dash $duration ease-in-out infinite, 24 | colors ($duration*4) ease-in-out infinite; 25 | } 26 | 27 | } 28 | 29 | @keyframes rotator { 30 | 0% { transform: rotate(0deg); } 31 | 100% { transform: rotate(270deg); } 32 | } 33 | 34 | @keyframes colors { 35 | 0% { stroke: #4285F4; } 36 | 25% { stroke: #DE3E35; } 37 | 50% { stroke: #F7C223; } 38 | 75% { stroke: #1B9A59; } 39 | 100% { stroke: #4285F4; } 40 | } 41 | 42 | @keyframes dash { 43 | 0% { 44 | stroke-dashoffset: $offset; 45 | } 46 | 50% { 47 | stroke-dashoffset: $offset/4; 48 | transform: rotate(135deg); 49 | } 50 | 100% { 51 | stroke-dashoffset: $offset; 52 | transform: rotate(450deg); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/assets/css/menu.scss: -------------------------------------------------------------------------------- 1 | menu { 2 | margin: 0; 3 | padding: 1rem; 4 | width: 100%; 5 | 6 | a { 7 | color: #6a6f7d; 8 | font-size: 1.1rem; 9 | line-height: 1.1rem; 10 | margin-right: 1rem; 11 | display: inline-block; 12 | cursor: pointer; 13 | text-decoration: none; 14 | transition: color 0.2s ease-in-out; 15 | 16 | &:hover, &.selected { 17 | color: #000; 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nightwolfz/inferno-starter/15cc8163c87f7d30b378b432c02fd3f4ee3114cb/src/assets/favicon.ico -------------------------------------------------------------------------------- /src/assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nightwolfz/inferno-starter/15cc8163c87f7d30b378b432c02fd3f4ee3114cb/src/assets/favicon.png -------------------------------------------------------------------------------- /src/assets/fonts/fontawesome-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nightwolfz/inferno-starter/15cc8163c87f7d30b378b432c02fd3f4ee3114cb/src/assets/fonts/fontawesome-webfont.woff -------------------------------------------------------------------------------- /src/assets/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "background_color": "#ffffff", 3 | "theme_color": "#ffffff", 4 | "name": "Inferno-site", 5 | "short_name": "Inferno-site", 6 | "display": "standalone", 7 | "icons": [ 8 | { 9 | "src": "/assets/favicon.png", 10 | "sizes": "320x320", 11 | "type": "image/png" 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /src/assets/service.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2016 Google Inc. All rights reserved. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | // DO NOT EDIT THIS GENERATED OUTPUT DIRECTLY! 18 | // This file should be overwritten as part of your build process. 19 | // If you need to extend the behavior of the generated service worker, the best approach is to write 20 | // additional code and include it using the importScripts option: 21 | // https://github.com/GoogleChrome/sw-precache#importscripts-arraystring 22 | // 23 | // Alternatively, it's possible to make changes to the underlying template file and then use that as the 24 | // new base for generating output, via the templateFilePath option: 25 | // https://github.com/GoogleChrome/sw-precache#templatefilepath-string 26 | // 27 | // If you go that route, make sure that whenever you update your sw-precache dependency, you reconcile any 28 | // changes made to this original template file with your modified copy. 29 | 30 | // This generated service worker JavaScript will precache your site's resources. 31 | // The code needs to be saved in a .js file at the top-level of your site, and registered 32 | // from your pages in order to be used. See 33 | // https://github.com/googlechrome/sw-precache/blob/master/demo/app/js/service-worker-registration.js 34 | // for an example of how you can register this script and handle various service worker events. 35 | 36 | /* eslint-env worker, serviceworker */ 37 | /* eslint-disable indent, no-unused-vars, no-multiple-empty-lines, max-nested-callbacks, space-before-function-paren, quotes, comma-spacing */ 38 | 'use strict'; 39 | 40 | var precacheConfig = [["/","d41d8cd98f00b204e9800998ecf8427e"],["/build/bundle-966181ca.js","0dc865702071c4632d446332f9097be9"],["/build/bundle-c854dc3b.css","f112db08681bd745bab7478eabf13b48"]]; 41 | var cacheName = 'sw-precache-v2-inferno-starter-cache-' + (self.registration ? self.registration.scope : ''); 42 | 43 | 44 | var ignoreUrlParametersMatching = [/^utm_/]; 45 | 46 | 47 | 48 | var addDirectoryIndex = function (originalUrl, index) { 49 | var url = new URL(originalUrl); 50 | if (url.pathname.slice(-1) === '/') { 51 | url.pathname += index; 52 | } 53 | return url.toString(); 54 | }; 55 | 56 | var createCacheKey = function (originalUrl, paramName, paramValue, 57 | dontCacheBustUrlsMatching) { 58 | // Create a new URL object to avoid modifying originalUrl. 59 | var url = new URL(originalUrl); 60 | 61 | // If dontCacheBustUrlsMatching is not set, or if we don't have a match, 62 | // then add in the extra cache-busting URL parameter. 63 | if (!dontCacheBustUrlsMatching || 64 | !(url.toString().match(dontCacheBustUrlsMatching))) { 65 | url.search += (url.search ? '&' : '') + 66 | encodeURIComponent(paramName) + '=' + encodeURIComponent(paramValue); 67 | } 68 | 69 | return url.toString(); 70 | }; 71 | 72 | var isPathWhitelisted = function (whitelist, absoluteUrlString) { 73 | // If the whitelist is empty, then consider all URLs to be whitelisted. 74 | if (whitelist.length === 0) { 75 | return true; 76 | } 77 | 78 | // Otherwise compare each path regex to the path of the URL passed in. 79 | var path = (new URL(absoluteUrlString)).pathname; 80 | return whitelist.some(function(whitelistedPathRegex) { 81 | return path.match(whitelistedPathRegex); 82 | }); 83 | }; 84 | 85 | var stripIgnoredUrlParameters = function (originalUrl, 86 | ignoreUrlParametersMatching) { 87 | var url = new URL(originalUrl); 88 | 89 | url.search = url.search.slice(1) // Exclude initial '?' 90 | .split('&') // Split into an array of 'key=value' strings 91 | .map(function(kv) { 92 | return kv.split('='); // Split each 'key=value' string into a [key, value] array 93 | }) 94 | .filter(function(kv) { 95 | return ignoreUrlParametersMatching.every(function(ignoredRegex) { 96 | return !ignoredRegex.test(kv[0]); // Return true iff the key doesn't match any of the regexes. 97 | }); 98 | }) 99 | .map(function(kv) { 100 | return kv.join('='); // Join each [key, value] array into a 'key=value' string 101 | }) 102 | .join('&'); // Join the array of 'key=value' strings into a string with '&' in between each 103 | 104 | return url.toString(); 105 | }; 106 | 107 | 108 | var hashParamName = '_sw-precache'; 109 | var urlsToCacheKeys = new Map( 110 | precacheConfig.map(function(item) { 111 | var relativeUrl = item[0]; 112 | var hash = item[1]; 113 | var absoluteUrl = new URL(relativeUrl, self.location); 114 | var cacheKey = createCacheKey(absoluteUrl, hashParamName, hash, false); 115 | return [absoluteUrl.toString(), cacheKey]; 116 | }) 117 | ); 118 | 119 | function setOfCachedUrls(cache) { 120 | return cache.keys().then(function(requests) { 121 | return requests.map(function(request) { 122 | return request.url; 123 | }); 124 | }).then(function(urls) { 125 | return new Set(urls); 126 | }); 127 | } 128 | 129 | self.addEventListener('install', function(event) { 130 | event.waitUntil( 131 | caches.open(cacheName).then(function(cache) { 132 | return setOfCachedUrls(cache).then(function(cachedUrls) { 133 | return Promise.all( 134 | Array.from(urlsToCacheKeys.values()).map(function(cacheKey) { 135 | // If we don't have a key matching url in the cache already, add it. 136 | if (!cachedUrls.has(cacheKey)) { 137 | return cache.add(new Request(cacheKey, { 138 | credentials: 'same-origin', 139 | redirect: 'follow' 140 | })); 141 | } 142 | }) 143 | ); 144 | }); 145 | }).then(function() { 146 | 147 | // Force the SW to transition from installing -> active state 148 | return self.skipWaiting(); 149 | 150 | }) 151 | ); 152 | }); 153 | 154 | self.addEventListener('activate', function(event) { 155 | var setOfExpectedUrls = new Set(urlsToCacheKeys.values()); 156 | 157 | event.waitUntil( 158 | caches.open(cacheName).then(function(cache) { 159 | return cache.keys().then(function(existingRequests) { 160 | return Promise.all( 161 | existingRequests.map(function(existingRequest) { 162 | if (!setOfExpectedUrls.has(existingRequest.url)) { 163 | return cache.delete(existingRequest); 164 | } 165 | }) 166 | ); 167 | }); 168 | }).then(function() { 169 | 170 | return self.clients.claim(); 171 | 172 | }) 173 | ); 174 | }); 175 | 176 | 177 | self.addEventListener('fetch', function(event) { 178 | if (event.request.method === 'GET') { 179 | // Should we call event.respondWith() inside this fetch event handler? 180 | // This needs to be determined synchronously, which will give other fetch 181 | // handlers a chance to handle the request if need be. 182 | var shouldRespond; 183 | 184 | // First, remove all the ignored parameter and see if we have that URL 185 | // in our cache. If so, great! shouldRespond will be true. 186 | var url = stripIgnoredUrlParameters(event.request.url, ignoreUrlParametersMatching); 187 | shouldRespond = urlsToCacheKeys.has(url); 188 | 189 | // If shouldRespond is false, check again, this time with 'index.html' 190 | // (or whatever the directoryIndex option is set to) at the end. 191 | var directoryIndex = 'index.html'; 192 | if (!shouldRespond && directoryIndex) { 193 | url = addDirectoryIndex(url, directoryIndex); 194 | shouldRespond = urlsToCacheKeys.has(url); 195 | } 196 | 197 | // If shouldRespond is still false, check to see if this is a navigation 198 | // request, and if so, whether the URL matches navigateFallbackWhitelist. 199 | var navigateFallback = '/'; 200 | if (!shouldRespond && 201 | navigateFallback && 202 | (event.request.mode === 'navigate') && 203 | isPathWhitelisted(["^\\/page\\/"], event.request.url)) { 204 | url = new URL(navigateFallback, self.location).toString(); 205 | shouldRespond = urlsToCacheKeys.has(url); 206 | } 207 | 208 | // If shouldRespond was set to true at any point, then call 209 | // event.respondWith(), using the appropriate cache key. 210 | if (shouldRespond) { 211 | event.respondWith( 212 | caches.open(cacheName).then(function(cache) { 213 | return cache.match(urlsToCacheKeys.get(url)).then(function(response) { 214 | if (response) { 215 | return response; 216 | } 217 | throw Error('The cached response that was expected is missing.'); 218 | }); 219 | }).catch(function(e) { 220 | // Fall back to just fetch()ing the request if some unexpected error 221 | // prevented the cached response from being valid. 222 | console.warn('Couldn\'t serve response for "%s" from cache: %O', event.request.url, e); 223 | return fetch(event.request); 224 | }) 225 | ); 226 | } 227 | } 228 | }); 229 | 230 | 231 | // *** Start of auto-included sw-toolbox code. *** 232 | /* 233 | Copyright 2016 Google Inc. All Rights Reserved. 234 | 235 | Licensed under the Apache License, Version 2.0 (the "License"); 236 | you may not use this file except in compliance with the License. 237 | You may obtain a copy of the License at 238 | 239 | http://www.apache.org/licenses/LICENSE-2.0 240 | 241 | Unless required by applicable law or agreed to in writing, software 242 | distributed under the License is distributed on an "AS IS" BASIS, 243 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 244 | See the License for the specific language governing permissions and 245 | limitations under the License. 246 | */!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)}); 247 | 248 | 249 | // *** End of auto-included sw-toolbox code. *** 250 | 251 | 252 | 253 | // Runtime cache configuration, using the sw-toolbox library. 254 | 255 | toolbox.router.get(/^http:\/\/localhost:2000\/api/, toolbox.networkFirst, {}); 256 | 257 | 258 | 259 | 260 | -------------------------------------------------------------------------------- /src/components/Main.js: -------------------------------------------------------------------------------- 1 | import Inferno from 'inferno' 2 | import Component from 'inferno-component' 3 | import Menu from '../components/common/Menu' 4 | 5 | /** 6 | * Here you can define other providers 7 | * such as for redux 8 | */ 9 | export default class Main extends Component { 10 | render({ children }) { 11 | return ( 12 |
13 | 14 | {children} 15 |
16 | ) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/components/common/Error.js: -------------------------------------------------------------------------------- 1 | import Inferno from 'inferno' 2 | 3 | function Error(props) { 4 | return

5 | {props.text ? props.text : props.children} 6 |

7 | } 8 | 9 | export default Error 10 | -------------------------------------------------------------------------------- /src/components/common/Loading.js: -------------------------------------------------------------------------------- 1 | import Inferno from 'inferno' 2 | 3 | export default function Loading() { 4 | // Loading animation... 5 | return
6 | 11 | 18 | 19 | 20 |
21 | } 22 | -------------------------------------------------------------------------------- /src/components/common/Menu.js: -------------------------------------------------------------------------------- 1 | import Inferno from 'inferno' 2 | import Component from 'inferno-component' 3 | import { connect } from 'inferno-mobx' 4 | import { Link } from 'inferno-router' 5 | 6 | @connect(['store']) 7 | class Menu extends Component { 8 | render({ store }) { 9 | return ( 10 |
11 | {store.account.isLoggedIn() 12 | ? 13 | : } 14 |
15 | ) 16 | } 17 | } 18 | 19 | function LoggedInMenu() { 20 | return 21 | Browse 22 | About 23 | Logout 24 | 25 | } 26 | 27 | function LoggedOutMenu() { 28 | return 29 | Browse 30 | About 31 | Register 32 | Login 33 | 34 | } 35 | 36 | export default Menu 37 | -------------------------------------------------------------------------------- /src/components/home/AddTodo.js: -------------------------------------------------------------------------------- 1 | import Inferno from 'inferno' 2 | import Component from 'inferno-component' 3 | import { connect } from 'inferno-mobx' 4 | import { observable } from 'mobx' 5 | 6 | @connect(['store']) 7 | class AddTodo extends Component { 8 | 9 | @observable inputText = '' 10 | 11 | handleSubmit = async(e) => { 12 | e.preventDefault() 13 | const { store } = this.props 14 | await store.todos.add(this.inputText) 15 | this.inputText = '' 16 | } 17 | 18 | handleChange = (e) => { 19 | this.inputText = e.target.value 20 | } 21 | 22 | render() { 23 | return ( 24 |
25 |

26 | 33 |

34 |
35 | ) 36 | } 37 | } 38 | 39 | export default AddTodo 40 | -------------------------------------------------------------------------------- /src/components/home/Todo.js: -------------------------------------------------------------------------------- 1 | import Inferno from 'inferno' 2 | import Component from 'inferno-component' 3 | import { connect } from 'inferno-mobx' 4 | 5 | @connect(['store']) 6 | class Todo extends Component { 7 | render({ store, item }) { 8 | return ( 9 |
  • 10 |
    11 | 12 |
    14 |
  • 15 | ) 16 | } 17 | } 18 | 19 | export default Todo 20 | -------------------------------------------------------------------------------- /src/config/autorun.js: -------------------------------------------------------------------------------- 1 | import { autorun } from 'mobx' 2 | 3 | export default function({ state }) { 4 | 5 | // Update document title whenever it changes 6 | autorun(() => { 7 | if (state.common.title) { 8 | document.title = state.common.title 9 | } 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /src/config/client.js: -------------------------------------------------------------------------------- 1 | // This is the entry point for our client-side logic 2 | // The server-side has a similar configuration in `src/server/middleware/render.js` 3 | import '../assets/css/index.scss' 4 | import 'isomorphic-fetch' 5 | import 'core/polyfills' 6 | import 'core/globals' 7 | import 'core/logger' 8 | import onEnter from 'core/onEnter' 9 | import Inferno from 'inferno' 10 | import { Router, match } from 'inferno-router' 11 | import { Provider } from 'inferno-mobx' 12 | import createBrowserHistory from 'history/createBrowserHistory'; 13 | import autorun from './autorun' 14 | import createContext from './context' 15 | import State from '../stores/State' 16 | import routes from './routes' 17 | 18 | if (process.env.NODE_ENV !== 'production') { 19 | require('inferno-devtools') 20 | require('mobx-logger').enableLogging({ 21 | action: true, 22 | reaction: false, 23 | transaction: true, 24 | compute: false 25 | }) 26 | } else { 27 | require('./sw') 28 | } 29 | 30 | const context = createContext(new State(window.__STATE)) 31 | const history = createBrowserHistory() 32 | 33 | // React to changes 34 | autorun(context) 35 | 36 | // Fetch data on route change 37 | history.listen(location => { 38 | onEnter(match(routes, location), context) 39 | }) 40 | 41 | // Render our component according to our routes 42 | function renderApp() { 43 | Inferno.render( 44 | 45 | {routes} 46 | 47 | , document.getElementById('container')) 48 | } 49 | 50 | renderApp() 51 | 52 | // Enable hot reloading if available 53 | if (module.hot) { 54 | module.hot.accept(renderApp) 55 | } 56 | -------------------------------------------------------------------------------- /src/config/context.js: -------------------------------------------------------------------------------- 1 | import requestCreator from 'core/request' 2 | import Common from '../stores/Common' 3 | import Account from '../stores/Account' 4 | import Todos from '../stores/Todos' 5 | 6 | export default (state) => { 7 | const request = requestCreator(state.account.token) 8 | return { 9 | state, 10 | store: { 11 | common: new Common(request, state), 12 | account: new Account(request, state), 13 | todos: new Todos(request, state), 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/config/routes.js: -------------------------------------------------------------------------------- 1 | import Inferno from 'inferno' 2 | import { Route, IndexRoute } from 'inferno-router' 3 | import Main from '../components/Main' 4 | import NotFound from '../pages/404' 5 | import Todos from '../pages/Home' 6 | import About from '../pages/About' 7 | import Login from '../pages/Login' 8 | import Logout from '../pages/Logout' 9 | import Register from '../pages/Register' 10 | 11 | /** 12 | * Routes are defined here. 13 | * @param {object} - stores 14 | * @returns {object} 15 | */ 16 | export default ( 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | ) 26 | -------------------------------------------------------------------------------- /src/config/sw.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Cache assets if browser supports serviceworker 3 | */ 4 | if ('serviceWorker' in navigator) { 5 | // Delay registration until after the page has loaded, to ensure that our 6 | // precaching requests don't degrade the first visit experience. 7 | // See https://developers.google.com/web/fundamentals/instant-and-offline/service-worker/registration 8 | window.addEventListener('load', function() { 9 | // Your service-worker.js *must* be located at the top-level directory relative to your site. 10 | // It won't be able to control pages unless it's located at the same level or higher than them. 11 | // *Don't* register service worker file in, e.g., a scripts/ sub-directory! 12 | // See https://github.com/slightlyoff/ServiceWorker/issues/468 13 | navigator.serviceWorker.register('/service.js').then(function(reg) { 14 | // updatefound is fired if service-worker.js changes. 15 | reg.onupdatefound = function() { 16 | // The updatefound event implies that reg.installing is set; see 17 | // https://slightlyoff.github.io/ServiceWorker/spec/service_worker/index.html#service-worker-container-updatefound-event 18 | var installingWorker = reg.installing; 19 | 20 | installingWorker.onstatechange = function() { 21 | switch (installingWorker.state) { 22 | case 'installed': 23 | if (navigator.serviceWorker.controller) { 24 | // At this point, the old content will have been purged and the fresh content will 25 | // have been added to the cache. 26 | // It's the perfect time to display a "New content is available; please refresh." 27 | // message in the page's interface. 28 | console.log('New or updated content is available.'); 29 | } else { 30 | // At this point, everything has been precached. 31 | // It's the perfect time to display a "Content is cached for offline use." message. 32 | console.log('Content is now available offline!'); 33 | } 34 | break; 35 | 36 | case 'redundant': 37 | console.error('The installing service worker became redundant.'); 38 | break; 39 | } 40 | }; 41 | }; 42 | }).catch(function(e) { 43 | console.error('Error during service worker registration:', e); 44 | }); 45 | }); 46 | } 47 | -------------------------------------------------------------------------------- /src/pages/404.js: -------------------------------------------------------------------------------- 1 | import Inferno from 'inferno' 2 | import Component from 'inferno-component' 3 | import { connect } from 'inferno-mobx' 4 | import { Link } from 'inferno-router' 5 | 6 | @connect 7 | class NotFound extends Component { 8 | render() { 9 | return
    10 |

     

    11 |

    Page not found. Are you lost ?

    12 | 13 | Go to Homepage 14 |
    15 | } 16 | } 17 | 18 | export default NotFound 19 | -------------------------------------------------------------------------------- /src/pages/About.js: -------------------------------------------------------------------------------- 1 | import Inferno from 'inferno' 2 | import Component from 'inferno-component' 3 | import { connect } from 'inferno-mobx' 4 | 5 | @connect 6 | class About extends Component { 7 | 8 | // When route is loaded (isomorphic) 9 | static onEnter({ store }) { 10 | store.common.title = 'About' 11 | } 12 | 13 | render() { 14 | return
    15 |

    Inferno-starter

    16 |
    17 |

    18 | nightwolfz 19 |

    20 |

    21 | Created for the javascript community. May your reign never end! 22 |

    23 |

    24 | 25 | https://github.com/nightwolfz/inferno-starter 26 | 27 |

    28 |
    29 |
    30 | } 31 | } 32 | 33 | export default About 34 | -------------------------------------------------------------------------------- /src/pages/Home.js: -------------------------------------------------------------------------------- 1 | import Inferno from 'inferno' 2 | import Component from 'inferno-component' 3 | import { connect } from 'inferno-mobx' 4 | import AddTodo from '../components/home/AddTodo' 5 | import Todo from '../components/home/Todo' 6 | 7 | @connect(['state', 'store']) 8 | class Home extends Component { 9 | 10 | // When route is loaded (isomorphic) 11 | static async onEnter({ state, store }, params) { 12 | state.common.title = 'Home' 13 | await store.todos.browse() 14 | } 15 | 16 | render({ state }) { 17 | return ( 18 |
    19 |

    todos

    20 |
    21 | 22 |
    23 |
      24 | {state.todos.map((item, index) => { 25 | return 26 | })} 27 |
    28 |
    29 |
    30 |
    31 | ) 32 | } 33 | } 34 | 35 | export default Home 36 | -------------------------------------------------------------------------------- /src/pages/Login.js: -------------------------------------------------------------------------------- 1 | import Inferno from 'inferno' 2 | import Component from 'inferno-component' 3 | import { connect } from 'inferno-mobx' 4 | import Loading from '../components/common/Loading' 5 | import Error from '../components/common/Error' 6 | 7 | @connect(['state', 'store']) 8 | class Login extends Component { 9 | 10 | // When route is loaded (isomorphic) 11 | static onEnter({ state }) { 12 | state.common.title = 'Login' 13 | } 14 | 15 | state = { 16 | username: '', 17 | password: '', 18 | loading: false, 19 | error: null 20 | } 21 | 22 | handleChange = (e) => { 23 | this.setState({ 24 | [e.target.name]: e.target.value 25 | }) 26 | } 27 | 28 | handleLogin = (e) => { 29 | e.preventDefault() 30 | const { store } = this.props 31 | const { router } = this.context 32 | const { username, password } = this.state 33 | 34 | this.setState({ 35 | error: null, 36 | loading: true 37 | }) 38 | 39 | store.account.login({ username, password }).then(() => { 40 | router.push('/') 41 | }).catch(error => { 42 | this.setState({ 43 | error, 44 | loading: false, 45 | }) 46 | }) 47 | } 48 | 49 | render() { 50 | const { loading, error, username } = this.state 51 | 52 | if (loading) { 53 | return 54 | } 55 | 56 | return ( 57 |
    58 |

    sign-in

    59 |
    60 | 70 | 71 | 80 | 81 | {loading 82 | ? 83 | : 84 | } 85 | 86 | {error && } 87 | 88 |
    89 | ) 90 | } 91 | } 92 | 93 | export default Login 94 | -------------------------------------------------------------------------------- /src/pages/Logout.js: -------------------------------------------------------------------------------- 1 | import Inferno from 'inferno' 2 | import Component from 'inferno-component' 3 | import { connect } from 'inferno-mobx' 4 | import Loading from '../components/common/Loading' 5 | 6 | @connect(['store']) 7 | class Logout extends Component { 8 | 9 | // When route is loaded (isomorphic) 10 | static onEnter({ state }) { 11 | state.common.title = 'Logout' 12 | } 13 | 14 | state = { 15 | loading: false 16 | } 17 | 18 | handleLogout = () => { 19 | const { store } = this.props 20 | const { router } = this.context 21 | 22 | this.setState({ 23 | loading: true 24 | }) 25 | new Promise(resolve => setTimeout(resolve, 500)) 26 | .then(() => store.account.logout()) 27 | .then(() => router.push('/')) 28 | } 29 | 30 | render(_, { loading }) { 31 | return
    32 |
    33 |

    Do you want to log out ?

    34 |

    This will disconnect you and you will have to login again next time.

    35 | 36 | {loading 37 | ? 38 | : 39 | } 40 |
    41 |
    42 | } 43 | } 44 | 45 | export default Logout 46 | -------------------------------------------------------------------------------- /src/pages/Register.js: -------------------------------------------------------------------------------- 1 | import Inferno from 'inferno' 2 | import Component from 'inferno-component' 3 | import { connect } from 'inferno-mobx' 4 | import Error from '../components/common/Error' 5 | 6 | @connect(['state', 'store']) 7 | class Register extends Component { 8 | 9 | // When route is loaded (isomorphic) 10 | static onEnter({ state }) { 11 | state.common.title = 'Register' 12 | } 13 | 14 | state = { 15 | username: '', 16 | password: '', 17 | errorMsg: null, 18 | loading: false 19 | } 20 | 21 | handleChange = (key) => (e) => { 22 | this.setState({ [key]: e.target.value }) 23 | } 24 | 25 | handleSubmit = async(e) => { 26 | e.preventDefault() 27 | await this.handleRegister() 28 | } 29 | 30 | handleRegister = async() => { 31 | const { router } = this.context 32 | const { store } = this.props 33 | const { username, password } = this.state 34 | 35 | this.setState({ 36 | loading: true, 37 | errorMsg: null 38 | }) 39 | 40 | try { 41 | await store.account.register({ 42 | username, 43 | password 44 | }) 45 | router.push('/') 46 | } catch(error) { 47 | this.setState({ 48 | loading: false, 49 | errorMsg: error.toString() 50 | }) 51 | } 52 | } 53 | 54 | render() { 55 | const { username, password, loading, errorMsg } = this.state 56 | return
    57 |

    register

    58 |
    59 | 67 | 68 | 77 | 78 | {loading 79 | ? 80 | : 81 | } 82 | 83 | {errorMsg && } 84 | 85 |
    86 | } 87 | } 88 | 89 | export default Register 90 | -------------------------------------------------------------------------------- /src/pages/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {title} 6 | 7 | 8 | 11 | 12 | 13 |
    {children}
    14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/stores/Account.js: -------------------------------------------------------------------------------- 1 | import {action} from 'mobx' 2 | import {size, find} from 'lodash' 3 | 4 | export default class Account { 5 | 6 | constructor(request, state) { 7 | this.request = request 8 | this.state = state 9 | } 10 | 11 | isLoggedIn() { 12 | return size(this.state.account.username) 13 | } 14 | 15 | @action find(username) { 16 | return find(this.state.account.users, { username }) 17 | } 18 | 19 | @action login(params) { 20 | return this.request.post('api/account/login', params).then(account => { 21 | this.state.account = account 22 | }) 23 | } 24 | 25 | @action logout() { 26 | this.request.get('api/account/logout') 27 | this.state.account.username = null 28 | this.state.account.token = null 29 | } 30 | 31 | @action register(params) { 32 | return this.request.post('api/account/register', params).then(account => { 33 | this.state.account = account 34 | }) 35 | } 36 | } 37 | 38 | -------------------------------------------------------------------------------- /src/stores/Common.js: -------------------------------------------------------------------------------- 1 | import state from './State' 2 | 3 | export default class Common { 4 | setTitle(newTitle) { 5 | state.common.title = newTitle 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/stores/State.js: -------------------------------------------------------------------------------- 1 | import { extendObservable, toJS } from 'mobx' 2 | 3 | /** 4 | * This is our state, we update it 5 | * using the methods from other stores 6 | */ 7 | export default class State { 8 | constructor(state) { 9 | extendObservable(this, { 10 | 11 | account: { 12 | username: null, 13 | token: null, 14 | users: [] 15 | }, 16 | common: { 17 | title: 'Inferno-starter', 18 | statusCode: 200, 19 | hostname: 'localhost' 20 | }, 21 | todos: [] 22 | 23 | }, state) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/stores/Todos.js: -------------------------------------------------------------------------------- 1 | import {action} from 'mobx' 2 | 3 | export default class Todos { 4 | 5 | constructor(request, state) { 6 | this.request = request 7 | this.state = state 8 | } 9 | 10 | @action async add(text) { 11 | const result = await this.request.post(`api/todos/add`, { text }) 12 | this.state.todos.push(result) 13 | } 14 | 15 | @action async remove(item) { 16 | try { 17 | await this.request.post(`api/todos/remove`, { id: item.id }) 18 | this.state.todos.remove(item) 19 | } catch(err) { 20 | console.error(err) 21 | } 22 | } 23 | 24 | @action async browse() { 25 | this.state.todos = await this.request.get(`api/todos`) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /webpack.base.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const ExtractCSS = require('extract-text-webpack-plugin') 3 | const root = path.join.bind(path, __dirname) 4 | 5 | module.exports = { 6 | context: path.resolve(__dirname, 'src'), 7 | entry: {}, 8 | node: { 9 | global: true, 10 | fs: 'empty', 11 | net: 'empty', 12 | tls: 'empty', 13 | dns: 'empty', 14 | }, 15 | performance: { 16 | hints: false 17 | }, 18 | module: { 19 | loaders: [ 20 | { 21 | test: /\.js$/, 22 | loader: 'babel-loader', 23 | include: [ 24 | root('src'), 25 | root('core') 26 | ], 27 | query: { 28 | cacheDirectory: false, 29 | presets: [], 30 | plugins: [ 31 | "add-module-exports", 32 | "transform-object-rest-spread", 33 | "transform-decorators-legacy", 34 | "transform-class-properties", 35 | "inferno" 36 | ] 37 | } 38 | }, 39 | { 40 | test: /\.(jpg|png|svg)$/, 41 | use: 'url-loader?limit=100000', 42 | include: [ 43 | root('src/assets'), 44 | root('src/client/components') 45 | ] 46 | }, 47 | { 48 | test: /\.(ttf|otf|eot|woff2?)$/, 49 | use: 'file-loader', 50 | include: [ 51 | root('src/assets'), 52 | root('src/client/components') 53 | ] 54 | }, 55 | { 56 | test: /\.(css|scss)?$/, 57 | use: ExtractCSS.extract(['css-loader?sourceMap', 'sass-loader?sourceMap']), 58 | include: [ 59 | root('src/assets'), 60 | root('src/components') 61 | ] 62 | } 63 | ] 64 | }, 65 | 66 | output: { 67 | filename: 'bundle.js', 68 | path: root('build'), 69 | sourcePrefix: '', 70 | }, 71 | 72 | resolve: { 73 | alias: { 74 | 'core': root('core') 75 | }, 76 | }, 77 | 78 | plugins: [ 79 | new ExtractCSS({ 80 | filename: 'bundle.css', 81 | allChunks: false 82 | }) 83 | ] 84 | }; 85 | -------------------------------------------------------------------------------- /webpack.dev.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const webpack = require('webpack') 3 | const WebpackDevServer = require('webpack-dev-server') 4 | const config = require('./webpack.base.js') 5 | 6 | // Merge with base configuration 7 | //------------------------------- 8 | Object.assign(config, { 9 | cache: true, 10 | devtool: 'source-map', // eval eval-cheap-module-source-map source-map 11 | entry: { 12 | bundle: [ 13 | `webpack-dev-server/client?http://localhost:2002`, 14 | 'webpack/hot/only-dev-server', 15 | path.join(__dirname, 'src/config/client.js') 16 | ] 17 | }, 18 | output: { 19 | publicPath: 'http://localhost:2002/build/', 20 | libraryTarget: 'var', 21 | pathinfo: true 22 | } 23 | }) 24 | 25 | config.plugins.push( 26 | new webpack.HotModuleReplacementPlugin(), 27 | new webpack.NoEmitOnErrorsPlugin(), 28 | new webpack.NamedModulesPlugin(), 29 | new webpack.WatchIgnorePlugin([ 30 | path.join(__dirname, 'core'), 31 | path.join(__dirname, 'build') 32 | ]), 33 | new webpack.EnvironmentPlugin({ 34 | 'DEV': true, 35 | 'BROWSER': true, 36 | 'NODE_ENV': JSON.stringify('development'), 37 | }) 38 | ) 39 | 40 | // Run DEV server for hot-reloading 41 | //--------------------------------- 42 | const compiler = webpack(config) 43 | const port = 2002 44 | 45 | new WebpackDevServer(compiler, { 46 | publicPath: config.output.publicPath, 47 | headers: { 48 | 'Access-Control-Allow-Origin': '*', 49 | 'Access-Control-Expose-Headers': 'SourceMap,X-SourceMap' 50 | }, 51 | hot: true, 52 | historyApiFallback: true, 53 | watchOptions: { 54 | aggregateTimeout: 300, 55 | poll: false 56 | }, 57 | stats: { 58 | colors: true, 59 | hash: false, 60 | timings: false, 61 | version: false, 62 | chunks: false, 63 | modules: false, 64 | children: false, 65 | chunkModules: false 66 | } 67 | }).listen(port, '0.0.0.0', function(err) { 68 | if (err) return console.error(err) 69 | 70 | console.info('Running on port ' + port) 71 | }) 72 | -------------------------------------------------------------------------------- /webpack.prod.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const webpack = require('webpack') 3 | const BabiliPlugin = require("babili-webpack-plugin") 4 | const config = require('./webpack.base') 5 | const buildPath = path.join(__dirname, 'build') 6 | 7 | // Merge with base configuration 8 | //------------------------------- 9 | Object.assign(config, { 10 | cache: false, 11 | devtool: 'source-map', 12 | entry: { 13 | bundle: path.join(__dirname, 'src/config/client.js') 14 | }, 15 | output: { 16 | path: buildPath, 17 | publicPath: '/build/' 18 | } 19 | }) 20 | 21 | // Support for old browsers 22 | //------------------------------------ 23 | config.module.loaders.forEach(loader => { 24 | if (loader.loader === 'babel-loader') { 25 | loader.query.plugins = loader.query.plugins.concat([ 26 | "transform-es2015-arrow-functions", 27 | "transform-es2015-block-scoped-functions", 28 | "transform-es2015-block-scoping", 29 | "transform-es2015-classes", 30 | "transform-es2015-computed-properties", 31 | "transform-es2015-destructuring", 32 | "transform-es2015-literals", 33 | "transform-es2015-parameters", 34 | "transform-es2015-shorthand-properties", 35 | "transform-es2015-spread", 36 | "transform-es2015-template-literals", 37 | ["fast-async"] 38 | ]) 39 | } 40 | }) 41 | 42 | console.info('Environment: Production') 43 | 44 | // Production plugins 45 | //------------------------------- 46 | config.plugins = config.plugins.concat([ 47 | new webpack.optimize.OccurrenceOrderPlugin(), 48 | new BabiliPlugin({ evaluate: false }, { comments: false }), 49 | new webpack.EnvironmentPlugin({ 50 | 'DEV': false, 51 | 'BROWSER': true, 52 | 'NODE_ENV': JSON.stringify('production'), 53 | }) 54 | ]) 55 | 56 | // Sanity checks 57 | //------------------------------- 58 | if (config.devtool === 'eval') { 59 | throw new Error('Using "eval" source-maps may break the build') 60 | } 61 | 62 | // Compile everything for PROD 63 | //------------------------------- 64 | const compiler = webpack(config) 65 | compiler.run(function(err, stats) { 66 | if (err) throw err 67 | 68 | // Output stats 69 | console.log(stats.toString({ 70 | colors: true, 71 | hash: false, 72 | version: false, 73 | chunks: false, 74 | modules: false, 75 | children: false, 76 | chunkModules: false 77 | })) 78 | 79 | // Write a stats.json for the webpack bundle visualizer 80 | //writeWebpackStats(stats) 81 | 82 | if (stats.hasErrors()) { 83 | console.error(stats.compilation.errors.toString()) 84 | } 85 | console.info('Finished compiling') 86 | }) 87 | 88 | 89 | /** 90 | * Writes a stats.json for the webpack bundle visualizer 91 | * URL: https://chrisbateman.github.io/webpack-visualizer/ 92 | * @param stats 93 | */ 94 | function writeWebpackStats(stats) { 95 | const location = path.resolve(config.output.path, 'stats.json') 96 | require('fs').writeFileSync(location, JSON.stringify(stats.toJson())) 97 | console.info(`Wrote stats.json to ${location}`) 98 | } 99 | --------------------------------------------------------------------------------