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