Browse Source

refactor: Migrate to Vue components

NGPixel 8 năm trước cách đây
mục cha
commit
c20c935fa5

+ 3 - 2
.eslintrc.json

@@ -1,12 +1,10 @@
 {
   "extends": "standard",
-
   "env": {
     "node": true,
     "es6": true,
     "jest": true
   },
-
   "globals": {
     "document": false,
     "navigator": false,
@@ -16,5 +14,8 @@
     "ROOTPATH": true,
     "SERVERPATH": true,
     "IS_DEBUG": true
+  },
+  "rules": {
+    "space-before-function-paren": 0
   }
 }

+ 77 - 8
client/js/app.js

@@ -1,14 +1,64 @@
 'use strict'
 
-/* global alertsData */
+/* global alertsData, siteLang */
+/* eslint-disable no-new */
 
 import $ from 'jquery'
 import _ from 'lodash'
+import Vue from 'vue'
+import Vuex from 'vuex'
 import io from 'socket.io-client'
+import i18next from 'i18next'
+import i18nextXHR from 'i18next-xhr-backend'
+import VueI18Next from '@panter/vue-i18next'
 import Alerts from './components/alerts.js'
 import 'jquery-smooth-scroll'
 import 'jquery-sticky'
 
+// ====================================
+// Load Vue Components
+// ====================================
+
+import anchorComponent from './components/anchor.vue'
+import colorPickerComponent from './components/color-picker.vue'
+import loadingSpinnerComponent from './components/loading-spinner.vue'
+import searchComponent from './components/search.vue'
+
+import adminProfileComponent from './pages/admin-profile.component.js'
+import adminSettingsComponent from './pages/admin-settings.component.js'
+
+// ====================================
+// Initialize i18next
+// ====================================
+
+Vue.use(VueI18Next)
+
+i18next
+  .use(i18nextXHR)
+  .init({
+    backend: {
+      loadPath: '/js/i18n/{{lng}}.json'
+    },
+    lng: siteLang,
+    fallbackLng: siteLang
+  })
+
+// ====================================
+// Initialize Vuex
+// ====================================
+
+Vue.use(Vuex)
+
+const store = new Vuex.Store({
+  state: {
+    loading: false
+  },
+  mutations: {
+    startLoading: state => { state.loading = true },
+    stopLoading: state => { state.loading = false }
+  }
+})
+
 $(() => {
   // ====================================
   // Scroll
@@ -27,28 +77,47 @@ $(() => {
   // ====================================
 
   $(window).bind('beforeunload', () => {
-    $('#notifload').addClass('active')
+    store.commit('startLoading')
   })
   $(document).ajaxSend(() => {
-    $('#notifload').addClass('active')
+    store.commit('startLoading')
   }).ajaxComplete(() => {
-    $('#notifload').removeClass('active')
+    store.commit('stopLoading')
   })
 
-  var alerts = new Alerts()
+  var alerts = {}
+  /*var alerts = new Alerts()
   if (alertsData) {
     _.forEach(alertsData, (alertRow) => {
       alerts.push(alertRow)
     })
-  }
+  }*/
 
   // ====================================
   // Establish WebSocket connection
   // ====================================
 
-  var socket = io(window.location.origin)
+  let socket = io(window.location.origin)
+  window.socket = socket
 
-  require('./components/search.js')(socket)
+  // ====================================
+  // Bootstrap Vue
+  // ====================================
+
+  const i18n = new VueI18Next(i18next)
+  new Vue({
+    components: {
+      adminProfile: adminProfileComponent,
+      adminSettings: adminSettingsComponent,
+      anchor: anchorComponent,
+      colorPicker: colorPickerComponent,
+      loadingSpinner: loadingSpinnerComponent,
+      search: searchComponent
+    },
+    store,
+    i18n,
+    el: '#root'
+  })
 
   // ====================================
   // Pages logic

+ 17 - 0
client/js/components/anchor.vue

@@ -0,0 +1,17 @@
+<template>
+    <div>
+        <p>{{ msg }}</p>
+        <input type="text" v-model="msg" />
+    </div>
+</template>
+
+<script>
+  export default {
+    name: 'anchor',
+    data () {
+      return {
+          msg: 'Welcome to Your Vue.js App'
+      }
+    }
+  }
+</script>

+ 15 - 0
client/js/components/color-picker.vue

@@ -0,0 +1,15 @@
+<template lang="pug">
+  p.control
+    input.input(type='text', placeholder='#F0F0F0', v-model='color')
+</template>
+
+<script>
+  export default {
+    name: 'color-picker',
+    data () {
+      return {
+        color: '000000'
+      }
+    }
+  }
+</script>

+ 0 - 17
client/js/components/copy-path.vue

@@ -1,17 +0,0 @@
-<template>
-    <div>
-        <p>{{ msg }}</p>
-        <input type="text" v-model="msg" />
-    </div>
-</template>
-
-<script>
-    export default {
-        name: 'app',
-        data () {
-            return {
-                msg: 'Welcome to Your Vue.js App'
-            }
-        }
-    }
-</script>

+ 12 - 0
client/js/components/loading-spinner.vue

@@ -0,0 +1,12 @@
+<template lang="pug">
+  i.nav-item#notifload(v-bind:class='{ "is-active": loading }')
+</template>
+
+<script>
+  import { mapState } from 'vuex'
+
+  export default {
+    name: 'loading-spinner',
+    computed: mapState(['loading'])
+  }
+</script>

+ 0 - 87
client/js/components/search.js

@@ -1,87 +0,0 @@
-'use strict'
-
-import $ from 'jquery'
-import _ from 'lodash'
-import Vue from 'vue'
-
-module.exports = (socket) => {
-  if ($('#search-input').length) {
-    $('#search-input').focus()
-
-    $('.searchresults').css('display', 'block')
-
-    var vueHeader = new Vue({
-      el: '#header-container',
-      data: {
-        searchq: '',
-        searchres: [],
-        searchsuggest: [],
-        searchload: 0,
-        searchactive: false,
-        searchmoveidx: 0,
-        searchmovekey: '',
-        searchmovearr: []
-      },
-      watch: {
-        searchq: (val, oldVal) => {
-          vueHeader.searchmoveidx = 0
-          if (val.length >= 3) {
-            vueHeader.searchactive = true
-            vueHeader.searchload++
-            socket.emit('search', { terms: val }, (data) => {
-              vueHeader.searchres = data.match
-              vueHeader.searchsuggest = data.suggest
-              vueHeader.searchmovearr = _.concat([], vueHeader.searchres, vueHeader.searchsuggest)
-              if (vueHeader.searchload > 0) { vueHeader.searchload-- }
-            })
-          } else {
-            vueHeader.searchactive = false
-            vueHeader.searchres = []
-            vueHeader.searchsuggest = []
-            vueHeader.searchmovearr = []
-            vueHeader.searchload = 0
-          }
-        },
-        searchmoveidx: (val, oldVal) => {
-          if (val > 0) {
-            vueHeader.searchmovekey = (vueHeader.searchmovearr[val - 1])
-              ? 'res.' + vueHeader.searchmovearr[val - 1].entryPath
-              : 'sug.' + vueHeader.searchmovearr[val - 1]
-          } else {
-            vueHeader.searchmovekey = ''
-          }
-        }
-      },
-      methods: {
-        useSuggestion: (sug) => {
-          vueHeader.searchq = sug
-        },
-        closeSearch: () => {
-          vueHeader.searchq = ''
-        },
-        moveSelectSearch: () => {
-          if (vueHeader.searchmoveidx < 1) { return }
-          let i = vueHeader.searchmoveidx - 1
-
-          if (vueHeader.searchmovearr[i]) {
-            window.location.assign('/' + vueHeader.searchmovearr[i].entryPath)
-          } else {
-            vueHeader.searchq = vueHeader.searchmovearr[i]
-          }
-        },
-        moveDownSearch: () => {
-          if (vueHeader.searchmoveidx < vueHeader.searchmovearr.length) {
-            vueHeader.searchmoveidx++
-          }
-        },
-        moveUpSearch: () => {
-          if (vueHeader.searchmoveidx > 0) {
-            vueHeader.searchmoveidx--
-          }
-        }
-      }
-    })
-
-    $('main').on('click', vueHeader.closeSearch)
-  }
-}

+ 101 - 0
client/js/components/search.vue

@@ -0,0 +1,101 @@
+<template lang="pug">
+  .nav-item
+    p.control(v-bind:class='{ "is-loading": searchload > 0 }')
+      input.input#search-input(type='text', v-model='searchq', autofocus, @keyup.esc='closeSearch', @keyup.down='moveDownSearch', @keyup.up='moveUpSearch', @keyup.enter='moveSelectSearch', debounce='400', v-bind:placeholder='$t("search.placeholder")')
+
+    transition(name='searchresults')
+      .searchresults(v-show='searchactive', v-cloak)
+        p.searchresults-label {{ $t('search.results') }}
+        ul.searchresults-list
+          li(v-if='searchres.length === 0')
+            a: em {{ $t('search.nomatch') }}
+          li(v-for='sres in searchres', v-bind:class='{ "is-active": searchmovekey === "res." + sres.entryPath }')
+            a(v-bind:href='"/" + sres.entryPath') {{ sres.title }}
+        p.searchresults-label(v-if='searchsuggest.length > 0') {{ $t('search.didyoumean') }}
+        ul.searchresults-list(v-if='searchsuggest.length > 0')
+          li(v-for='sug in searchsuggest', v-bind:class='{ "is-active": searchmovekey === "sug." + sug }')
+            a(v-on:click='useSuggestion(sug)') {{ sug }}
+</template>
+
+<script>
+  import * as _ from 'lodash'
+  import * as $ from 'jquery'
+
+  export default {
+    data () {
+      return {
+        searchq: '',
+        searchres: [],
+        searchsuggest: [],
+        searchload: 0,
+        searchactive: false,
+        searchmoveidx: 0,
+        searchmovekey: '',
+        searchmovearr: []
+      }
+    },
+    watch: {
+      searchq: function (val, oldVal) {
+        let self = this
+        self.searchmoveidx = 0
+        if (val.length >= 3) {
+          self.searchactive = true
+          self.searchload++
+          socket.emit('search', { terms: val }, (data) => {
+            self.searchres = data.match
+            self.searchsuggest = data.suggest
+            self.searchmovearr = _.concat([], self.searchres, self.searchsuggest)
+            if (self.searchload > 0) { self.searchload-- }
+          })
+        } else {
+          self.searchactive = false
+          self.searchres = []
+          self.searchsuggest = []
+          self.searchmovearr = []
+          self.searchload = 0
+        }
+      },
+      searchmoveidx: function (val, oldVal) {
+        if (val > 0) {
+          this.searchmovekey = (this.searchmovearr[val - 1])
+            ? 'res.' + this.searchmovearr[val - 1].entryPath
+            : 'sug.' + this.searchmovearr[val - 1]
+        } else {
+          this.searchmovekey = ''
+        }
+      }
+    },
+    methods: {
+      useSuggestion: function (sug) {
+        this.searchq = sug
+      },
+      closeSearch: function() {
+        this.searchq = ''
+      },
+      moveSelectSearch: function () {
+        if (this.searchmoveidx < 1) { return }
+        let i = this.searchmoveidx - 1
+
+        if (this.searchmovearr[i]) {
+          window.location.assign('/' + this.searchmovearr[i].entryPath)
+        } else {
+          this.searchq = this.searchmovearr[i]
+        }
+      },
+      moveDownSearch: function () {
+        if (this.searchmoveidx < this.searchmovearr.length) {
+          this.searchmoveidx++
+        }
+      },
+      moveUpSearch: function () {
+        if (this.searchmoveidx > 0) {
+          this.searchmoveidx--
+        }
+      }
+    },
+    mounted: function () {
+      let self = this
+      $('main').on('click', self.closeSearch)
+    }
+  }
+</script>

+ 54 - 0
client/js/modals/create.vue

@@ -0,0 +1,54 @@
+<template lang="pug">
+  .modal(v-if='isShown')
+    .modal-background
+    .modal-container
+      .modal-content
+        header.is-light-blue Create New Document
+        section
+          label.label Enter the new document path:
+          p.control.is-fullwidth(v-class='{ "is-loading": isLoading }')
+            input.input(type='text', placeholder='page-name', v-model='entrypath', autofocus)
+            span.help.is-danger(v-show='isInvalid') This document path is invalid!
+        footer
+          a.button.is-grey.is-outlined(v-on:click='hide') Discard
+          a.button.is-light-blue(v-on:click='create') Create
+</template>
+
+<script>
+  import * as _ from 'lodash'
+  import { makeSafePath } from '../helpers/pages'
+
+  export default {
+    name: 'modal-create',
+    data () {
+      return {
+        entrypath: ''
+        isInvalid: false,
+        isLoading: false,
+        isShown: false
+      }
+    },
+    methods: {
+      show: function () {
+        this.isInvalid = false
+        this.shown = true
+      },
+      hide: function () {
+        this.shown = false
+      },
+      create: function () {
+        this.isInvalid = false
+        let newDocPath = makeSafePath(this.entrypath)
+        if (_.isEmpty(newDocPath)) {
+          this.isInvalid = true
+        } else {
+          $('#txt-create-prompt').parent().addClass('is-loading')
+          window.location.assign('/create/' + newDocPath)
+        }
+      }
+    },
+    mounted () {
+      this.entrypath = currentBasePath + '/new-page'
+    }
+  }
+</script>

+ 30 - 0
client/js/pages/admin-profile.component.js

@@ -0,0 +1,30 @@
+'use strict'
+
+import * as $ from 'jquery'
+
+export default {
+  name: 'admin-profile',
+  props: ['email', 'name', 'provider'],
+  data() {
+    return {
+      password: '********',
+      passwordVerify: '********'
+    }
+  },
+  methods: {
+    saveUser() {
+      if (this.password !== this.passwordVerify) {
+        //alerts.pushError('Error', "Passwords don't match!")
+        return
+      }
+      $.post(window.location.href, {
+        password: this.password,
+        name: this.name
+      }).done((resp) => {
+        //alerts.pushSuccess('Saved successfully', 'Changes have been applied.')
+      }).fail((jqXHR, txtStatus, resp) => {
+        //alerts.pushError('Error', resp)
+      })
+    }
+  }
+}

+ 52 - 0
client/js/pages/admin-settings.component.js

@@ -0,0 +1,52 @@
+'use strict'
+
+import * as $ from 'jquery'
+
+export default {
+  name: 'admin-settings',
+  data() {
+    return {
+      upgradeModal: {
+        state: false,
+        step: 'confirm',
+        mode: 'upgrade',
+        error: 'Something went wrong.'
+      }
+    }
+  },
+  methods: {
+    upgrade() {
+      this.upgradeModal.mode = 'upgrade'
+      this.upgradeModal.step = 'confirm'
+      this.upgradeModal.state = true
+    },
+    reinstall() {
+      this.upgradeModal.mode = 're-install'
+      this.upgradeModal.step = 'confirm'
+      this.upgradeModal.state = true
+    },
+    upgradeCancel() {
+      this.upgradeModal.state = false
+    },
+    upgradeStart() {
+      this.upgradeModal.step = 'running'
+      $.post('/admin/settings/install', {
+        mode: this.upgradeModal.mode
+      }).done((resp) => {
+        // todo
+      }).fail((jqXHR, txtStatus, resp) => {
+        this.upgradeModal.step = 'error'
+        this.upgradeModal.error = jqXHR.responseText
+      })
+    },
+    flushcache() {
+      window.alert('Coming soon!')
+    },
+    resetaccounts() {
+      window.alert('Coming soon!')
+    },
+    flushsessions() {
+      window.alert('Coming soon!')
+    }
+  }
+}

+ 2 - 77
client/js/pages/admin.js

@@ -1,41 +1,13 @@
 'use strict'
 
-/* global usrData, usrDataName */
+/* global usrData */
 
 import $ from 'jquery'
 import _ from 'lodash'
 import Vue from 'vue'
 
 module.exports = (alerts) => {
-  if ($('#page-type-admin-profile').length) {
-    let vueProfile = new Vue({
-      el: '#page-type-admin-profile',
-      data: {
-        password: '********',
-        passwordVerify: '********',
-        name: ''
-      },
-      methods: {
-        saveUser: (ev) => {
-          if (vueProfile.password !== vueProfile.passwordVerify) {
-            alerts.pushError('Error', "Passwords don't match!")
-            return
-          }
-          $.post(window.location.href, {
-            password: vueProfile.password,
-            name: vueProfile.name
-          }).done((resp) => {
-            alerts.pushSuccess('Saved successfully', 'Changes have been applied.')
-          }).fail((jqXHR, txtStatus, resp) => {
-            alerts.pushError('Error', resp)
-          })
-        }
-      },
-      created: function () {
-        this.name = usrDataName
-      }
-    })
-  } else if ($('#page-type-admin-users').length) {
+  if ($('#page-type-admin-users').length) {
     require('../modals/admin-users-create.js')(alerts)
   } else if ($('#page-type-admin-users-edit').length) {
     let vueEditUser = new Vue({
@@ -98,52 +70,5 @@ module.exports = (alerts) => {
       }
     })
     require('../modals/admin-users-delete.js')(alerts)
-  } else if ($('#page-type-admin-settings').length) {
-    let vueSettings = new Vue({ // eslint-disable-line no-unused-vars
-      el: '#page-type-admin-settings',
-      data: {
-        upgradeModal: {
-          state: false,
-          step: 'confirm',
-          mode: 'upgrade',
-          error: 'Something went wrong.'
-        }
-      },
-      methods: {
-        upgrade: (ev) => {
-          vueSettings.upgradeModal.mode = 'upgrade'
-          vueSettings.upgradeModal.step = 'confirm'
-          vueSettings.upgradeModal.state = true
-        },
-        reinstall: (ev) => {
-          vueSettings.upgradeModal.mode = 're-install'
-          vueSettings.upgradeModal.step = 'confirm'
-          vueSettings.upgradeModal.state = true
-        },
-        upgradeCancel: (ev) => {
-          vueSettings.upgradeModal.state = false
-        },
-        upgradeStart: (ev) => {
-          vueSettings.upgradeModal.step = 'running'
-          $.post('/admin/settings/install', {
-            mode: vueSettings.upgradeModal.mode
-          }).done((resp) => {
-            // todo
-          }).fail((jqXHR, txtStatus, resp) => {
-            vueSettings.upgradeModal.step = 'error'
-            vueSettings.upgradeModal.error = jqXHR.responseText
-          })
-        },
-        flushcache: (ev) => {
-          window.alert('Coming soon!')
-        },
-        resetaccounts: (ev) => {
-          window.alert('Coming soon!')
-        },
-        flushsessions: (ev) => {
-          window.alert('Coming soon!')
-        }
-      }
-    })
   }
 }

+ 5 - 5
client/js/pages/view.js

@@ -4,7 +4,7 @@
 
 import $ from 'jquery'
 import MathJax from 'mathjax'
-import * as CopyPath from '../components/copy-path.vue'
+// import * as CopyPath from '../components/copy-path.vue'
 import Vue from 'vue'
 
 module.exports = (alerts) => {
@@ -13,10 +13,10 @@ module.exports = (alerts) => {
 
     // Copy Path
 
-    new Vue({
-      el: '.modal-copypath',
-      render: h => h(CopyPath)
-    })
+    // new Vue({
+    //   el: '.modal-copypath',
+    //   render: h => h(CopyPath)
+    // })
 
     // MathJax Render
 

+ 25 - 16
client/scss/components/search.scss

@@ -1,47 +1,56 @@
 .searchresults {
 	position: fixed;
-	top: 45px;
+	top: 50px;
 	left: 0;
 	right: 0;
 	margin: 0 auto;
 	width: 500px;
 	z-index: 1;
-	background-color: mc($primary, '700');
-	border-bottom: 5px solid mc($primary, '800');
-	box-shadow: 0 0 5px mc($primary, '500');
+	background-color: darken(mc('blue-grey', '900'), 2%);
+  border: 1px solid mc('blue-grey', '900');
+	box-shadow: 0 0 5px mc('blue-grey', '500');
 	color: #FFF;
+  transition: max-height 1s ease;
 
-	&.slideInDown {
-		@include prefix(animation-duration, .6s);
-	}
+	&-enter-active, &-leave-active {
+    overflow: hidden;
+  }
+  &-enter-to, &-leave {
+    max-height: 500px;
+  }
+  &-enter, &-leave-to {
+    max-height: 0px;
+  }
 
 	.searchresults-label {
-		color: mc($primary, '200');
-		padding: 15px 10px 10px;
+    background-color: mc('blue-grey', '800');
+		color: mc('blue-grey', '300');
+		padding: 8px;
 		font-size: 13px;
-		text-transform: uppercase;
-		border-bottom: 1px dotted mc($primary, '400');
+    letter-spacing: 1px;
+    text-transform: uppercase;
+    box-shadow: 0 0 5px rgba(0,0,0,0.3);
 	}
 
 	.searchresults-list {
+    padding-bottom: 5px;
 
 		> li {
 			display: flex;
 			font-size: 14px;
-			transition: background-color .3s linear;
+			transition: background-color .2s linear;
 
 			&:nth-child(odd) {
-				background-color: mc($primary, '600');
+				background-color: mc('blue-grey', '900');
 			}
 
 			&.is-active, &:hover {
-				background-color: mc($primary, '400');
+				background-color: mc('blue-grey', '600');
 				color: #FFF;
-				border-left: 5px solid mc($primary, '200');
 			}
 
 			a {
-				color: mc($primary, '50');
+				color: mc('blue-grey', '50');
 				display: flex;
 				align-items: center;
 				height: 30px;

+ 2 - 2
client/scss/layout/_header.scss

@@ -24,7 +24,7 @@
 		@include spinner(mc('indigo', '100'),0.5s,24px);
 	}
 
-	&.active {
+	&.is-active {
 		opacity: 1;
 	}
 
@@ -33,4 +33,4 @@
 #search-input {
 	max-width: 300px;
 	width: 33vw;
-}
+}

+ 55 - 31
fuse.js

@@ -88,43 +88,66 @@ let globalTasks = Promise.mapSeries([
       if (err.code === 'ENOENT') {
         console.info(colors.white('  └── ') + colors.green('Copy MathJax dependencies to assets...'))
         return fs.ensureDirAsync('./assets/js/mathjax').then(() => {
-          return fs.copyAsync('./node_modules/mathjax', './assets/js/mathjax', { filter: (src, dest) => {
-            let srcNormalized = src.replace(/\\/g, '/')
-            let shouldCopy = false
-            console.log(srcNormalized)
-            _.forEach([
-              '/node_modules/mathjax',
-              '/node_modules/mathjax/jax',
-              '/node_modules/mathjax/jax/input',
-              '/node_modules/mathjax/jax/output'
-            ], chk => {
-              if (srcNormalized.endsWith(chk)) {
-                shouldCopy = true
+          return fs.copyAsync('./node_modules/mathjax', './assets/js/mathjax', {
+            filter: (src, dest) => {
+              let srcNormalized = src.replace(/\\/g, '/')
+              let shouldCopy = false
+              console.info(colors.white('      ' + srcNormalized))
+              _.forEach([
+                '/node_modules/mathjax',
+                '/node_modules/mathjax/jax',
+                '/node_modules/mathjax/jax/input',
+                '/node_modules/mathjax/jax/output'
+              ], chk => {
+                if (srcNormalized.endsWith(chk)) {
+                  shouldCopy = true
+                }
+              })
+              _.forEach([
+                '/node_modules/mathjax/extensions',
+                '/node_modules/mathjax/MathJax.js',
+                '/node_modules/mathjax/jax/element',
+                '/node_modules/mathjax/jax/input/MathML',
+                '/node_modules/mathjax/jax/input/TeX',
+                '/node_modules/mathjax/jax/output/SVG'
+              ], chk => {
+                if (srcNormalized.indexOf(chk) > 0) {
+                  shouldCopy = true
+                }
+              })
+              if (shouldCopy && srcNormalized.indexOf('/fonts/') > 0 && srcNormalized.indexOf('/STIX-Web') <= 1) {
+                shouldCopy = false
               }
-            })
-            _.forEach([
-              '/node_modules/mathjax/extensions',
-              '/node_modules/mathjax/MathJax.js',
-              '/node_modules/mathjax/jax/element',
-              '/node_modules/mathjax/jax/input/MathML',
-              '/node_modules/mathjax/jax/input/TeX',
-              '/node_modules/mathjax/jax/output/SVG'
-            ], chk => {
-              if (srcNormalized.indexOf(chk) > 0) {
-                shouldCopy = true
-              }
-            })
-            if (shouldCopy && srcNormalized.indexOf('/fonts/') > 0 && srcNormalized.indexOf('/STIX-Web') <= 1) {
-              shouldCopy = false
+              return shouldCopy
             }
-            return shouldCopy
-          }})
+          })
         })
       } else {
         throw err
       }
     })
   },
+  /**
+   * i18n
+   */
+  () => {
+    console.info(colors.white('  └── ') + colors.green('Copying i18n client files...'))
+    return fs.ensureDirAsync('./assets/js/i18n').then(() => {
+      return fs.readJsonAsync('./server/locales/en/browser.json').then(enContent => {
+        return fs.readdirAsync('./server/locales').then(langs => {
+          return Promise.map(langs, lang => {
+            console.info(colors.white('      ' + lang + '.json'))
+            let outputPath = path.join('./assets/js/i18n', lang + '.json')
+            return fs.readJsonAsync(path.join('./server/locales', lang + '.json'), 'utf8').then((content) => {
+              return fs.outputJsonAsync(outputPath, _.defaultsDeep(content, enContent))
+            }).catch(err => { // eslint-disable-line handle-callback-err
+              return fs.outputJsonAsync(outputPath, enContent)
+            })
+          })
+        })
+      })
+    })
+  },
   /**
    * Bundle pre-init scripts
    */
@@ -144,6 +167,7 @@ let globalTasks = Promise.mapSeries([
    * Delete Fusebox cache
    */
   () => {
+    console.info(colors.white('  └── ') + colors.green('Clearing fuse-box cache...'))
     return fs.emptyDirAsync('./.fusebox')
   }
 ], f => { return f() })
@@ -156,7 +180,7 @@ const ALIASES = {
   'brace-ext-modelist': 'brace/ext/modelist.js',
   'simplemde': 'simplemde/dist/simplemde.min.js',
   'socket.io-client': 'socket.io-client/dist/socket.io.js',
-  'vue': 'vue/dist/vue.min.js'
+  'vue': (dev) ? 'vue/dist/vue.js' : 'vue/dist/vue.min.js'
 }
 const SHIMS = {
   _preinit: {
@@ -182,7 +206,7 @@ globalTasks.then(() => {
     plugins: [
       fsbx.EnvPlugin({ NODE_ENV: (dev) ? 'development' : 'production' }),
       fsbx.VuePlugin(),
-      [ '.scss', fsbx.SassPlugin({ outputStyle: (dev) ? 'nested' : 'compressed' }), fsbx.CSSPlugin() ],
+      ['.scss', fsbx.SassPlugin({ outputStyle: (dev) ? 'nested' : 'compressed' }), fsbx.CSSPlugin()],
       fsbx.BabelPlugin({ comments: false, presets: ['es2015'] }),
       fsbx.JSONPlugin(),
       !dev && fsbx.UglifyJSPlugin({

+ 8 - 4
package.json

@@ -65,7 +65,7 @@
     "fs-extra": "^3.0.1",
     "git-wrapper2-promise": "^0.2.9",
     "highlight.js": "^9.11.0",
-    "i18next": "^8.2.0",
+    "i18next": "^8.3.0",
     "i18next-express-middleware": "^1.0.5",
     "i18next-node-fs-backend": "^1.0.0",
     "image-size": "^0.5.4",
@@ -78,7 +78,7 @@
     "markdown-it": "^8.3.1",
     "markdown-it-abbr": "^1.0.4",
     "markdown-it-anchor": "^4.0.0",
-    "markdown-it-attrs": "^0.8.0",
+    "markdown-it-attrs": "^0.9.0",
     "markdown-it-emoji": "^1.3.0",
     "markdown-it-expand-tabs": "^1.0.12",
     "markdown-it-external-links": "0.0.6",
@@ -126,11 +126,13 @@
   },
   "devDependencies": {
     "@glimpse/glimpse": "^0.20.9",
+    "@panter/vue-i18next": "^0.4.1",
     "babel-cli": "latest",
     "babel-jest": "latest",
     "babel-preset-es2015": "latest",
     "brace": "^0.10.0",
     "colors": "^1.1.2",
+    "consolidate": "^0.14.5",
     "eslint": "latest",
     "eslint-config-standard": "latest",
     "eslint-plugin-import": "latest",
@@ -138,6 +140,7 @@
     "eslint-plugin-promise": "latest",
     "eslint-plugin-standard": "latest",
     "fuse-box": "^2.0.0",
+    "i18next-xhr-backend": "^1.4.1",
     "jest": "latest",
     "jquery": "^3.2.1",
     "jquery-contextmenu": "^2.4.5",
@@ -155,7 +158,8 @@
     "vee-validate": "^2.0.0-rc.3",
     "vue": "^2.3.3",
     "vue-template-compiler": "^2.3.3",
-    "vue-template-es2015-compiler": "^1.5.2"
+    "vue-template-es2015-compiler": "^1.5.2",
+    "vuex": "^2.3.1"
   },
   "jest": {
     "collectCoverage": false,
@@ -166,4 +170,4 @@
     "verbose": true
   },
   "snyk": true
-}
+}

+ 7 - 0
server/controllers/admin.js

@@ -255,4 +255,11 @@ router.post('/settings/install', (req, res) => {
   res.status(400).send('Sorry, Upgrade/Re-Install via the web UI is not yet ready. You must use the npm upgrade method in the meantime.').end()
 })
 
+router.get('/theme', (req, res) => {
+  if (!res.locals.rights.manage) {
+    return res.render('error-forbidden')
+  }
+  res.render('pages/admin/theme', { adminTab: 'theme' })
+})
+
 module.exports = router

+ 1 - 1
server/locales/en/admin.json

@@ -48,4 +48,4 @@
     "edituser": "Edit User",
     "uniqueid": "Unique ID"
   }
-}
+}

+ 16 - 0
server/locales/en/browser.json

@@ -0,0 +1,16 @@
+{
+  "profile": {
+    "displayname": "Display Name",
+    "displaynameexample": "John Smith",
+    "email": "Email",
+    "password": "Password",
+    "passwordverify": "Verify Password",
+    "savechanges": "Save Changes"
+  },
+  "search": {
+    "placeholder": "Search...",
+    "results": "Search Results",
+    "nomatch": "No results matching your query",
+    "didyoumean": "Did you mean...?"
+  }
+}

+ 3 - 7
server/locales/en/common.json

@@ -9,12 +9,6 @@
     "home": "Home",
     "top": "Return to top"
   },
-  "search": {
-    "placeholder": "Search...",
-    "results": "Search Results",
-    "nomatch": "No results matching your query",
-    "didyoumean": "Did you mean...?"
-  },
   "sidebar": {
     "nav": "NAV",
     "navigation": "Navigation",
@@ -24,9 +18,11 @@
   "nav": {
     "home": "Home",
     "account": "Account",
+    "settings": "Settings",
     "myprofile": "My Profile",
     "stats": "Stats",
     "syssettings": "System Settings",
+    "theme": "Color Theme",
     "users": "Users",
     "logout": "Logout",
     "create": "Create",
@@ -51,4 +47,4 @@
     "source": "Loading source...",
     "editor": "Loading editor..."
   }
-}
+}

+ 3 - 1
server/locales/fr/common.json

@@ -24,9 +24,11 @@
   "nav": {
     "home": "Accueil",
     "account": "Compte",
+    "settings": "Paramètres",
     "myprofile": "Mon Profil",
     "stats": "Statistiques",
     "syssettings": "Paramètres système",
+    "theme": "Thème de couleur",
     "users": "Utilisateurs",
     "logout": "Se Déconnecter",
     "create": "Créer",
@@ -51,4 +53,4 @@
     "source": "Chargement de la source...",
     "editor": "Chargement de l'éditeur"
   }
-}
+}

+ 2 - 17
server/views/common/header.pug

@@ -9,26 +9,11 @@
             = appconfig.title
     .nav-center
       block rootNavCenter
-        .nav-item
-          p.control(v-bind:class='{ "is-loading": searchload > 0 }')
-            input.input#search-input(type='text', v-model='searchq', @keyup.esc='closeSearch', @keyup.down='moveDownSearch', @keyup.up='moveUpSearch', @keyup.enter='moveSelectSearch', debounce='400', placeholder=t('search.placeholder'))
+        search
     span.nav-toggle
       span
       span
       span
     .nav-right
       block rootNavRight
-        i.nav-item#notifload
-
-  transition(name='searchresults-anim', enter-active-class='slideInDown', leave-active-class='fadeOutUp')
-    .searchresults.animated(v-show='searchactive', v-cloak, style={'display':'none'})
-      p.searchresults-label= t('search.results')
-      ul.searchresults-list
-        li(v-if='searchres.length === 0')
-          a: em= t('search.nomatch')
-        li(v-for='sres in searchres', v-bind:class='{ "is-active": searchmovekey === "res." + sres.entryPath }')
-          a(v-bind:href='"/" + sres.entryPath') {{ sres.title }}
-      p.searchresults-label(v-if='searchsuggest.length > 0')= t('search.didyoumean')
-      ul.searchresults-list(v-if='searchsuggest.length > 0')
-        li(v-for='sug in searchsuggest', v-bind:class='{ "is-active": searchmovekey === "sug." + sug }')
-          a(v-on:click='useSuggestion(sug)') {{ sug }}
+        loading-spinner

+ 6 - 3
server/views/layout.pug

@@ -9,7 +9,7 @@ html
     meta(name='msapplication-TileImage', content='/favicons/ms-icon-144x144.png')
     title= appconfig.title
 
-    // Favicon
+    //- Favicon
     each favsize in [57, 60, 72, 76, 114, 120, 144, 152, 180]
       link(rel='apple-touch-icon', sizes=favsize + 'x' + favsize, href='/favicons/apple-icon-' + favsize + 'x' + favsize + '.png')
     link(rel='icon', type='image/png', sizes='192x192', href='/favicons/android-icon-192x192.png')
@@ -17,7 +17,10 @@ html
       link(rel='icon', type='image/png', sizes=favsize + 'x' + favsize, href='/favicons/favicon-' + favsize + 'x' + favsize + '.png')
     link(rel='manifest', href='/manifest.json')
 
-    // JS / CSS
+    //- Site Lang
+    script var siteLang = '!{appconfig.lang}';
+
+    //- JS / CSS
     script(type='text/javascript', src='/js/libs.min.js')
     script(type='text/javascript', src='/js/app.min.js')
 
@@ -26,7 +29,7 @@ html
   body
     #root.has-stickynav
       include ./common/header.pug
-      include ./common/alerts.pug
+      //-include ./common/alerts.pug
       main
         block content
       include ./common/footer.pug

+ 5 - 1
server/views/pages/admin/_layout.pug

@@ -4,7 +4,7 @@ block rootNavCenter
   h2.nav-item= t('nav.account')
 
 block rootNavRight
-  i.nav-item#notifload
+  loading-spinner
   .nav-item
     a.button.btn-edit-discard(href='/')
       i.icon-home
@@ -48,6 +48,10 @@ block content
                   a(href='/admin/settings')
                     i.icon-cog
                     span= t('nav.syssettings')
+                li
+                  a(href='/admin/theme')
+                    i.icon-drop
+                    span= t('nav.theme')
               li
                 a(href='/logout')
                   i.icon-delete2

+ 44 - 46
server/views/pages/admin/profile.pug

@@ -1,53 +1,51 @@
 extends ./_layout.pug
 
 block adminContent
-  #page-type-admin-profile
-    .hero
-      h1.title#title= t('nav.myprofile')
-      h2.subtitle= t('admin:profile.subtitle')
-    .form-sections
-      .columns.is-gapless
-        .column.is-two-thirds
-          section
-            label.label= t('admin:profile.email')
-            p.control.is-fullwidth
-              input.input(type='text', placeholder=t('admin:profile.email'), value=user.email, disabled)
-          if user.provider === 'local'
+  .hero
+    h1.title#title= t('nav.myprofile')
+    h2.subtitle= t('admin:profile.subtitle')
+  .form-sections
+    .columns.is-gapless
+      .column.is-two-thirds
+        admin-profile(inline-template, email=user.email, name=user.name, provider=user.provider)
+          div
             section
-              label.label= t('admin:profile.password')
+              label.label= t('admin:profile.email')
               p.control.is-fullwidth
-                input.input(type='password', placeholder=t('admin:profile.password'), value='********', v-model='password')
+                input.input(type='text', placeholder=t('admin:profile.email'), value=user.email, disabled)
+            if user.provider === 'local'
+              section
+                label.label= t('admin:profile.password')
+                p.control.is-fullwidth
+                  input.input(type='password', placeholder=t('admin:profile.password'), value='********', v-model='password')
+              section
+                label.label= t('admin:profile.passwordverify')
+                p.control.is-fullwidth
+                  input.input(type='password', placeholder=t('admin:profile.password'), value='********', v-model='passwordVerify')
             section
-              label.label= t('admin:profile.passwordverify')
+              label.label= t('admin:profile.displayname')
               p.control.is-fullwidth
-                input.input(type='password', placeholder=t('admin:profile.password'), value='********', v-model='passwordVerify')
-          section
-            label.label= t('admin:profile.displayname')
-            p.control.is-fullwidth
-              input.input(type='text', placeholder=t('admin:profile.displaynameexample'), v-model='name')
-          section
-            button.button.is-green(v-on:click='saveUser')
-              i.icon-check
-              span= t('admin:profile.savechanges')
-        .column
-          .panel-aside
-            label.label= t('admin:profile.provider')
-            p.control.account-profile-provider
-              case user.provider
-                when 'local': i.icon-server
-                when 'windowslive': i.icon-windows2.is-blue
-                when 'azure': i.icon-windows2.is-blue
-                when 'google': i.icon-google.is-blue
-                when 'facebook': i.icon-facebook.is-indigo
-                when 'github': i.icon-github.is-grey
-                when 'slack': i.icon-slack.is-purple
-                when 'ldap': i.icon-arrow-repeat-outline
-                default: i.icon-warning
-              = t('auth:providers.' + user.provider)
-            label.label= t('admin:profile.membersince')
-            p.control= moment(user.createdAt).format('LL')
-            label.label= t('admin:profile.lastprofileupdate')
-            p.control= moment(user.updatedAt).format('LL')
-
-  script(type='text/javascript').
-    var usrDataName = "!{user.name}";
+                input.input(type='text', placeholder=t('admin:profile.displaynameexample'), v-model='name')
+            section
+              button.button.is-green(v-on:click='saveUser')
+                i.icon-check
+                span= t('admin:profile.savechanges')
+      .column
+        .panel-aside
+          label.label= t('admin:profile.provider')
+          p.control.account-profile-provider
+            case user.provider
+              when 'local': i.icon-server
+              when 'windowslive': i.icon-windows2.is-blue
+              when 'azure': i.icon-windows2.is-blue
+              when 'google': i.icon-google.is-blue
+              when 'facebook': i.icon-facebook.is-indigo
+              when 'github': i.icon-github.is-grey
+              when 'slack': i.icon-slack.is-purple
+              when 'ldap': i.icon-arrow-repeat-outline
+              default: i.icon-warning
+            = t('auth:providers.' + user.provider)
+          label.label= t('admin:profile.membersince')
+          p.control= moment(user.createdAt).format('LL')
+          label.label= t('admin:profile.lastprofileupdate')
+          p.control= moment(user.updatedAt).format('LL')

+ 5 - 5
server/views/pages/admin/settings.pug

@@ -1,10 +1,10 @@
 extends ./_layout.pug
 
 block adminContent
-  #page-type-admin-settings
-    .hero
-      h1.title#title= t('nav.syssettings')
-      h2.subtitle= t('admin:settings.subtitle')
+  .hero
+    h1.title#title= t('nav.syssettings')
+    h2.subtitle= t('admin:settings.subtitle')
+  admin-settings(inline-template)
     .form-sections
       section
         img(src='/images/logo.png', style={width:'200px', float:'right'})
@@ -34,4 +34,4 @@ block adminContent
           p.is-small= t('admin:settings.flushsessionstext')
           p: button.button.is-teal.is-outlined(v-on:click='flushsessions')= t('admin:settings.flushsessionsbtn')
 
-    include ../../modals/admin-upgrade.pug
+      include ../../modals/admin-upgrade.pug

+ 11 - 0
server/views/pages/admin/theme.pug

@@ -0,0 +1,11 @@
+extends ./_layout.pug
+
+block adminContent
+  #page-type-admin-settings
+    .hero
+      h1.title#title= t('nav.theme')
+      h2.subtitle= t('admin:theme.subtitle')
+    .form-sections
+      section
+        label.label= t('admin:theme.primarycolor')
+        color-picker

+ 2 - 2
server/views/pages/view.pug

@@ -57,8 +57,8 @@ block content
               if !isGuest
                 li
                   a(href='/admin')
-                    i.icon-head
-                    span= t('nav.account')
+                    i.icon-cog
+                    span= t('nav.settings')
               else
                 li
                   a(href='/login')

+ 163 - 151
yarn.lock

@@ -53,6 +53,10 @@
     "@glimpse/glimpse-server" "0.20.9"
     lodash "^4.15.0"
 
+"@panter/vue-i18next@^0.4.1":
+  version "0.4.1"
+  resolved "https://registry.yarnpkg.com/@panter/vue-i18next/-/vue-i18next-0.4.1.tgz#6b06b783cd4d8f2c80255457d3fa0db6aff1091c"
+
 abab@^1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/abab/-/abab-1.0.3.tgz#b81de5f7274ec4e756d797cd834f303642724e5d"
@@ -505,13 +509,13 @@ babel-helpers@^6.24.1:
     babel-runtime "^6.22.0"
     babel-template "^6.24.1"
 
-babel-jest@^20.0.1, babel-jest@latest:
-  version "20.0.1"
-  resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-20.0.1.tgz#9cbe9a15bbe3f1ca1b727dc8e45a4161771d3655"
+babel-jest@^20.0.3, babel-jest@latest:
+  version "20.0.3"
+  resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-20.0.3.tgz#e4a03b13dc10389e140fc645d09ffc4ced301671"
   dependencies:
     babel-core "^6.0.0"
     babel-plugin-istanbul "^4.0.0"
-    babel-preset-jest "^20.0.1"
+    babel-preset-jest "^20.0.3"
 
 babel-messages@^6.23.0:
   version "6.23.0"
@@ -533,9 +537,9 @@ babel-plugin-istanbul@^4.0.0:
     istanbul-lib-instrument "^1.7.1"
     test-exclude "^4.1.0"
 
-babel-plugin-jest-hoist@^20.0.1:
-  version "20.0.1"
-  resolved "https://registry.yarnpkg.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-20.0.1.tgz#1b9cc322cff704d3812d1bca8dccd12205eedfd5"
+babel-plugin-jest-hoist@^20.0.3:
+  version "20.0.3"
+  resolved "https://registry.yarnpkg.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-20.0.3.tgz#afedc853bd3f8dc3548ea671fbe69d03cc2c1767"
 
 babel-plugin-transform-es2015-arrow-functions@^6.22.0:
   version "6.22.0"
@@ -755,11 +759,11 @@ babel-preset-es2015@latest:
     babel-plugin-transform-es2015-unicode-regex "^6.24.1"
     babel-plugin-transform-regenerator "^6.24.1"
 
-babel-preset-jest@^20.0.1:
-  version "20.0.1"
-  resolved "https://registry.yarnpkg.com/babel-preset-jest/-/babel-preset-jest-20.0.1.tgz#8a9e23ce8a0f0c49835de53ed73ecf75dd6daa2e"
+babel-preset-jest@^20.0.3:
+  version "20.0.3"
+  resolved "https://registry.yarnpkg.com/babel-preset-jest/-/babel-preset-jest-20.0.3.tgz#cbacaadecb5d689ca1e1de1360ebfc66862c178a"
   dependencies:
-    babel-plugin-jest-hoist "^20.0.1"
+    babel-plugin-jest-hoist "^20.0.3"
 
 babel-register@^6.24.1:
   version "6.24.1"
@@ -921,7 +925,7 @@ bluebird@2.10.2:
   version "2.10.2"
   resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-2.10.2.tgz#024a5517295308857f14f91f1106fc3b555f446b"
 
-bluebird@^3.0, bluebird@^3.4.1, bluebird@^3.5.0:
+bluebird@^3.0, bluebird@^3.1.1, bluebird@^3.4.1, bluebird@^3.5.0:
   version "3.5.0"
   resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.0.tgz#791420d7f551eea2897453a8a77653f96606d67c"
 
@@ -1399,6 +1403,12 @@ console-control-strings@^1.0.0, console-control-strings@~1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e"
 
+consolidate@^0.14.5:
+  version "0.14.5"
+  resolved "https://registry.yarnpkg.com/consolidate/-/consolidate-0.14.5.tgz#5a25047bc76f73072667c8cb52c989888f494c63"
+  dependencies:
+    bluebird "^3.1.1"
+
 constantinople@^3.0.1:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/constantinople/-/constantinople-3.1.0.tgz#7569caa8aa3f8d5935d62e1fa96f9f702cd81c79"
@@ -2936,9 +2946,13 @@ i18next-node-fs-backend@^1.0.0:
     js-yaml "3.5.4"
     json5 "0.5.0"
 
-i18next@^8.2.0:
-  version "8.2.1"
-  resolved "https://registry.yarnpkg.com/i18next/-/i18next-8.2.1.tgz#6d2e8884516c320b4020c5af63e0316be626ac95"
+i18next-xhr-backend@^1.4.1:
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/i18next-xhr-backend/-/i18next-xhr-backend-1.4.1.tgz#ade99356065f51742da9e4bc79c7bb4905a4b91d"
+
+i18next@^8.3.0:
+  version "8.3.0"
+  resolved "https://registry.yarnpkg.com/i18next/-/i18next-8.3.0.tgz#04739294a665d204cab5fe4f4dd3e9bc3750ace4"
 
 iconv-lite@0.4.13:
   version "0.4.13"
@@ -3361,13 +3375,13 @@ istanbul-reports@^1.1.0:
   dependencies:
     handlebars "^4.0.3"
 
-jest-changed-files@^20.0.1:
-  version "20.0.1"
-  resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-20.0.1.tgz#ba9bd42c3fddb1b7c4ae40065199b44a2335e152"
+jest-changed-files@^20.0.3:
+  version "20.0.3"
+  resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-20.0.3.tgz#9394d5cc65c438406149bef1bf4d52b68e03e3f8"
 
-jest-cli@^20.0.1:
-  version "20.0.1"
-  resolved "https://registry.yarnpkg.com/jest-cli/-/jest-cli-20.0.1.tgz#86ca0bc2e47215ad8e7dc85455c0210f86648502"
+jest-cli@^20.0.3:
+  version "20.0.3"
+  resolved "https://registry.yarnpkg.com/jest-cli/-/jest-cli-20.0.3.tgz#fe88ddbb7a9f3a16d0ed55339a0a2424f7f0d361"
   dependencies:
     ansi-escapes "^1.4.0"
     callsites "^2.0.0"
@@ -3378,18 +3392,18 @@ jest-cli@^20.0.1:
     istanbul-lib-coverage "^1.0.1"
     istanbul-lib-instrument "^1.4.2"
     istanbul-lib-source-maps "^1.1.0"
-    jest-changed-files "^20.0.1"
-    jest-config "^20.0.1"
-    jest-docblock "^20.0.1"
-    jest-environment-jsdom "^20.0.1"
-    jest-haste-map "^20.0.1"
-    jest-jasmine2 "^20.0.1"
-    jest-message-util "^20.0.1"
-    jest-regex-util "^20.0.1"
-    jest-resolve-dependencies "^20.0.1"
-    jest-runtime "^20.0.1"
-    jest-snapshot "^20.0.1"
-    jest-util "^20.0.1"
+    jest-changed-files "^20.0.3"
+    jest-config "^20.0.3"
+    jest-docblock "^20.0.3"
+    jest-environment-jsdom "^20.0.3"
+    jest-haste-map "^20.0.3"
+    jest-jasmine2 "^20.0.3"
+    jest-message-util "^20.0.3"
+    jest-regex-util "^20.0.3"
+    jest-resolve-dependencies "^20.0.3"
+    jest-runtime "^20.0.3"
+    jest-snapshot "^20.0.3"
+    jest-util "^20.0.3"
     micromatch "^2.3.11"
     node-notifier "^5.0.2"
     pify "^2.3.0"
@@ -3400,177 +3414,177 @@ jest-cli@^20.0.1:
     worker-farm "^1.3.1"
     yargs "^7.0.2"
 
-jest-config@^20.0.1:
-  version "20.0.1"
-  resolved "https://registry.yarnpkg.com/jest-config/-/jest-config-20.0.1.tgz#c6934f585c3e1775c96133efb302f986c3909ad8"
+jest-config@^20.0.3:
+  version "20.0.3"
+  resolved "https://registry.yarnpkg.com/jest-config/-/jest-config-20.0.3.tgz#a934f27eea764915801cdda26f6f8eec2ac79266"
   dependencies:
     chalk "^1.1.3"
     glob "^7.1.1"
-    jest-environment-jsdom "^20.0.1"
-    jest-environment-node "^20.0.1"
-    jest-jasmine2 "^20.0.1"
-    jest-matcher-utils "^20.0.1"
-    jest-regex-util "^20.0.1"
-    jest-resolve "^20.0.1"
-    jest-validate "^20.0.1"
-    pretty-format "^20.0.1"
-
-jest-diff@^20.0.1:
-  version "20.0.1"
-  resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-20.0.1.tgz#2567c80c324243328321386f8871a28ec9d350ac"
+    jest-environment-jsdom "^20.0.3"
+    jest-environment-node "^20.0.3"
+    jest-jasmine2 "^20.0.3"
+    jest-matcher-utils "^20.0.3"
+    jest-regex-util "^20.0.3"
+    jest-resolve "^20.0.3"
+    jest-validate "^20.0.3"
+    pretty-format "^20.0.3"
+
+jest-diff@^20.0.3:
+  version "20.0.3"
+  resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-20.0.3.tgz#81f288fd9e675f0fb23c75f1c2b19445fe586617"
   dependencies:
     chalk "^1.1.3"
     diff "^3.2.0"
-    jest-matcher-utils "^20.0.1"
-    pretty-format "^20.0.1"
+    jest-matcher-utils "^20.0.3"
+    pretty-format "^20.0.3"
 
-jest-docblock@^20.0.1:
-  version "20.0.1"
-  resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-20.0.1.tgz#055e0bbcb76798198479901f92d2733bf619f854"
+jest-docblock@^20.0.3:
+  version "20.0.3"
+  resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-20.0.3.tgz#17bea984342cc33d83c50fbe1545ea0efaa44712"
 
-jest-environment-jsdom@^20.0.1:
-  version "20.0.1"
-  resolved "https://registry.yarnpkg.com/jest-environment-jsdom/-/jest-environment-jsdom-20.0.1.tgz#2d29f81368987d387c70ce4f500c6aa560f9b4f7"
+jest-environment-jsdom@^20.0.3:
+  version "20.0.3"
+  resolved "https://registry.yarnpkg.com/jest-environment-jsdom/-/jest-environment-jsdom-20.0.3.tgz#048a8ac12ee225f7190417713834bb999787de99"
   dependencies:
-    jest-mock "^20.0.1"
-    jest-util "^20.0.1"
+    jest-mock "^20.0.3"
+    jest-util "^20.0.3"
     jsdom "^9.12.0"
 
-jest-environment-node@^20.0.1:
-  version "20.0.1"
-  resolved "https://registry.yarnpkg.com/jest-environment-node/-/jest-environment-node-20.0.1.tgz#75ab5358072ee1efebc54f43474357d7b3d674c7"
+jest-environment-node@^20.0.3:
+  version "20.0.3"
+  resolved "https://registry.yarnpkg.com/jest-environment-node/-/jest-environment-node-20.0.3.tgz#d488bc4612af2c246e986e8ae7671a099163d403"
   dependencies:
-    jest-mock "^20.0.1"
-    jest-util "^20.0.1"
+    jest-mock "^20.0.3"
+    jest-util "^20.0.3"
 
-jest-haste-map@^20.0.1:
-  version "20.0.1"
-  resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-20.0.1.tgz#e6ba4db99ab512e7c081a5b0a0af731d0e193d56"
+jest-haste-map@^20.0.3:
+  version "20.0.3"
+  resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-20.0.3.tgz#6377d537eaf34eb5f75121a691cae3fde82ba971"
   dependencies:
     fb-watchman "^2.0.0"
     graceful-fs "^4.1.11"
-    jest-docblock "^20.0.1"
+    jest-docblock "^20.0.3"
     micromatch "^2.3.11"
     sane "~1.6.0"
     worker-farm "^1.3.1"
 
-jest-jasmine2@^20.0.1:
-  version "20.0.1"
-  resolved "https://registry.yarnpkg.com/jest-jasmine2/-/jest-jasmine2-20.0.1.tgz#675772b1fd32ad74e92e8ae8282f8ea71d1de168"
+jest-jasmine2@^20.0.3:
+  version "20.0.3"
+  resolved "https://registry.yarnpkg.com/jest-jasmine2/-/jest-jasmine2-20.0.3.tgz#18c4e9d029da7ed1ae727c55300064d1a0542974"
   dependencies:
     chalk "^1.1.3"
     graceful-fs "^4.1.11"
-    jest-diff "^20.0.1"
-    jest-matcher-utils "^20.0.1"
-    jest-matchers "^20.0.1"
-    jest-message-util "^20.0.1"
-    jest-snapshot "^20.0.1"
+    jest-diff "^20.0.3"
+    jest-matcher-utils "^20.0.3"
+    jest-matchers "^20.0.3"
+    jest-message-util "^20.0.3"
+    jest-snapshot "^20.0.3"
     once "^1.4.0"
     p-map "^1.1.1"
 
-jest-matcher-utils@^20.0.1:
-  version "20.0.1"
-  resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-20.0.1.tgz#31aef67f59535af3c2271a3a3685db604dbd1622"
+jest-matcher-utils@^20.0.3:
+  version "20.0.3"
+  resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-20.0.3.tgz#b3a6b8e37ca577803b0832a98b164f44b7815612"
   dependencies:
     chalk "^1.1.3"
-    pretty-format "^20.0.1"
+    pretty-format "^20.0.3"
 
-jest-matchers@^20.0.1:
-  version "20.0.1"
-  resolved "https://registry.yarnpkg.com/jest-matchers/-/jest-matchers-20.0.1.tgz#053b7654ce60129268f39992886e987a5201bb90"
+jest-matchers@^20.0.3:
+  version "20.0.3"
+  resolved "https://registry.yarnpkg.com/jest-matchers/-/jest-matchers-20.0.3.tgz#ca69db1c32db5a6f707fa5e0401abb55700dfd60"
   dependencies:
-    jest-diff "^20.0.1"
-    jest-matcher-utils "^20.0.1"
-    jest-message-util "^20.0.1"
-    jest-regex-util "^20.0.1"
+    jest-diff "^20.0.3"
+    jest-matcher-utils "^20.0.3"
+    jest-message-util "^20.0.3"
+    jest-regex-util "^20.0.3"
 
-jest-message-util@^20.0.1:
-  version "20.0.1"
-  resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-20.0.1.tgz#ac21cb055a6a5786b7f127ac7e705df5ffb1c335"
+jest-message-util@^20.0.3:
+  version "20.0.3"
+  resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-20.0.3.tgz#6aec2844306fcb0e6e74d5796c1006d96fdd831c"
   dependencies:
     chalk "^1.1.3"
     micromatch "^2.3.11"
     slash "^1.0.0"
 
-jest-mock@^20.0.1:
-  version "20.0.1"
-  resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-20.0.1.tgz#f4cca2e87e441b66fabe4ead6a6d61773ec0773a"
+jest-mock@^20.0.3:
+  version "20.0.3"
+  resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-20.0.3.tgz#8bc070e90414aa155c11a8d64c869a0d5c71da59"
 
-jest-regex-util@^20.0.1:
-  version "20.0.1"
-  resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-20.0.1.tgz#ecbcca8fbe4e217bca7f6f42a9b831d051224dc4"
+jest-regex-util@^20.0.3:
+  version "20.0.3"
+  resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-20.0.3.tgz#85bbab5d133e44625b19faf8c6aa5122d085d762"
 
-jest-resolve-dependencies@^20.0.1:
-  version "20.0.1"
-  resolved "https://registry.yarnpkg.com/jest-resolve-dependencies/-/jest-resolve-dependencies-20.0.1.tgz#38fc012191775b0b277fabebb37aa8282e26846f"
+jest-resolve-dependencies@^20.0.3:
+  version "20.0.3"
+  resolved "https://registry.yarnpkg.com/jest-resolve-dependencies/-/jest-resolve-dependencies-20.0.3.tgz#6e14a7b717af0f2cb3667c549de40af017b1723a"
   dependencies:
-    jest-regex-util "^20.0.1"
+    jest-regex-util "^20.0.3"
 
-jest-resolve@^20.0.1:
-  version "20.0.1"
-  resolved "https://registry.yarnpkg.com/jest-resolve/-/jest-resolve-20.0.1.tgz#cace553663f25c703dc977a4ce176e29eda92772"
+jest-resolve@^20.0.3:
+  version "20.0.3"
+  resolved "https://registry.yarnpkg.com/jest-resolve/-/jest-resolve-20.0.3.tgz#375307aa40f78532d40ff8b17d5300b1519f8dd4"
   dependencies:
     browser-resolve "^1.11.2"
     is-builtin-module "^1.0.0"
     resolve "^1.3.2"
 
-jest-runtime@^20.0.1:
-  version "20.0.1"
-  resolved "https://registry.yarnpkg.com/jest-runtime/-/jest-runtime-20.0.1.tgz#384f9298b8e8a177870c6d9ad0023db10ddcaedc"
+jest-runtime@^20.0.3:
+  version "20.0.3"
+  resolved "https://registry.yarnpkg.com/jest-runtime/-/jest-runtime-20.0.3.tgz#dddd22bbc429e26e6a96d1acd46ca55714b09252"
   dependencies:
     babel-core "^6.0.0"
-    babel-jest "^20.0.1"
+    babel-jest "^20.0.3"
     babel-plugin-istanbul "^4.0.0"
     chalk "^1.1.3"
     convert-source-map "^1.4.0"
     graceful-fs "^4.1.11"
-    jest-config "^20.0.1"
-    jest-haste-map "^20.0.1"
-    jest-regex-util "^20.0.1"
-    jest-resolve "^20.0.1"
-    jest-util "^20.0.1"
+    jest-config "^20.0.3"
+    jest-haste-map "^20.0.3"
+    jest-regex-util "^20.0.3"
+    jest-resolve "^20.0.3"
+    jest-util "^20.0.3"
     json-stable-stringify "^1.0.1"
     micromatch "^2.3.11"
     strip-bom "3.0.0"
     yargs "^7.0.2"
 
-jest-snapshot@^20.0.1:
-  version "20.0.1"
-  resolved "https://registry.yarnpkg.com/jest-snapshot/-/jest-snapshot-20.0.1.tgz#3704c599705042f20ec7c95ba76a4524c744dfac"
+jest-snapshot@^20.0.3:
+  version "20.0.3"
+  resolved "https://registry.yarnpkg.com/jest-snapshot/-/jest-snapshot-20.0.3.tgz#5b847e1adb1a4d90852a7f9f125086e187c76566"
   dependencies:
     chalk "^1.1.3"
-    jest-diff "^20.0.1"
-    jest-matcher-utils "^20.0.1"
-    jest-util "^20.0.1"
+    jest-diff "^20.0.3"
+    jest-matcher-utils "^20.0.3"
+    jest-util "^20.0.3"
     natural-compare "^1.4.0"
-    pretty-format "^20.0.1"
+    pretty-format "^20.0.3"
 
-jest-util@^20.0.1:
-  version "20.0.1"
-  resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-20.0.1.tgz#a3e7afb67110b2c3ac77b82e9a51ca57f4ff72a1"
+jest-util@^20.0.3:
+  version "20.0.3"
+  resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-20.0.3.tgz#0c07f7d80d82f4e5a67c6f8b9c3fe7f65cfd32ad"
   dependencies:
     chalk "^1.1.3"
     graceful-fs "^4.1.11"
-    jest-message-util "^20.0.1"
-    jest-mock "^20.0.1"
-    jest-validate "^20.0.1"
+    jest-message-util "^20.0.3"
+    jest-mock "^20.0.3"
+    jest-validate "^20.0.3"
     leven "^2.1.0"
     mkdirp "^0.5.1"
 
-jest-validate@^20.0.1:
-  version "20.0.1"
-  resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-20.0.1.tgz#a236c29e3c29e9b92a1e5da211a732f0238da928"
+jest-validate@^20.0.3:
+  version "20.0.3"
+  resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-20.0.3.tgz#d0cfd1de4f579f298484925c280f8f1d94ec3cab"
   dependencies:
     chalk "^1.1.3"
-    jest-matcher-utils "^20.0.1"
+    jest-matcher-utils "^20.0.3"
     leven "^2.1.0"
-    pretty-format "^20.0.1"
+    pretty-format "^20.0.3"
 
 jest@latest:
-  version "20.0.1"
-  resolved "https://registry.yarnpkg.com/jest/-/jest-20.0.1.tgz#4e268159ccc3b659966939de817c75bfe9e0157d"
+  version "20.0.3"
+  resolved "https://registry.yarnpkg.com/jest/-/jest-20.0.3.tgz#e4fd054c4f1170a116a00761da4cfdb73f1cdc33"
   dependencies:
-    jest-cli "^20.0.1"
+    jest-cli "^20.0.3"
 
 "jimp@https://github.com/ngpixel/jimp.git":
   version "0.2.27"
@@ -4204,9 +4218,9 @@ markdown-it-anchor@^4.0.0:
   dependencies:
     string "^3.3.3"
 
-markdown-it-attrs@^0.8.0:
-  version "0.8.0"
-  resolved "https://registry.yarnpkg.com/markdown-it-attrs/-/markdown-it-attrs-0.8.0.tgz#11ad35725f01d7e249e897d7ce8b21403dc28f16"
+markdown-it-attrs@^0.9.0:
+  version "0.9.0"
+  resolved "https://registry.yarnpkg.com/markdown-it-attrs/-/markdown-it-attrs-0.9.0.tgz#dd4dfff1ad0b7acbf16bbfa3a97041da08c25fdd"
 
 markdown-it-emoji@^1.3.0:
   version "1.3.0"
@@ -4517,11 +4531,7 @@ mv@~2:
     ncp "~2.0.0"
     rimraf "~2.4.0"
 
-nan@^2.3.0, nan@^2.3.2, nan@^2.3.3:
-  version "2.6.2"
-  resolved "https://registry.yarnpkg.com/nan/-/nan-2.6.2.tgz#e4ff34e6c95fdfb5aecc08de6596f43605a7db45"
-
-nan@~2.5.1:
+nan@^2.3.0, nan@^2.3.2, nan@^2.3.3, nan@~2.5.1:
   version "2.5.1"
   resolved "https://registry.yarnpkg.com/nan/-/nan-2.5.1.tgz#d5b01691253326a97a2bbee9e61c55d8d60351e2"
 
@@ -4613,8 +4623,8 @@ node-pre-gyp@^0.6.29:
     tar-pack "^3.4.0"
 
 node-sass@latest:
-  version "4.5.2"
-  resolved "https://registry.yarnpkg.com/node-sass/-/node-sass-4.5.2.tgz#4012fa2bd129b1d6365117e88d9da0500d99da64"
+  version "4.5.3"
+  resolved "https://registry.yarnpkg.com/node-sass/-/node-sass-4.5.3.tgz#d09c9d1179641239d1b97ffc6231fdcec53e1568"
   dependencies:
     async-foreach "^0.1.3"
     chalk "^1.1.1"
@@ -5301,9 +5311,9 @@ preserve@^0.2.0:
   version "0.2.0"
   resolved "https://registry.yarnpkg.com/preserve/-/preserve-0.2.0.tgz#815ed1f6ebc65926f865b310c0713bcb3315ce4b"
 
-pretty-format@^20.0.1:
-  version "20.0.1"
-  resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-20.0.1.tgz#ba95329771907c189643dd251e244061ff642350"
+pretty-format@^20.0.3:
+  version "20.0.3"
+  resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-20.0.3.tgz#020e350a560a1fe1a98dc3beb6ccffb386de8b14"
   dependencies:
     ansi-regex "^2.1.1"
     ansi-styles "^3.0.0"
@@ -6214,8 +6224,8 @@ snyk-try-require@^1.1.1, snyk-try-require@^1.2.0:
     then-fs "^2.0.0"
 
 snyk@latest:
-  version "1.30.0"
-  resolved "https://registry.yarnpkg.com/snyk/-/snyk-1.30.0.tgz#a323809ea477d6aff0e325f5995cb491c0d7ca3d"
+  version "1.30.1"
+  resolved "https://registry.yarnpkg.com/snyk/-/snyk-1.30.1.tgz#0cf14c1d73c7b6f63ca4e275ac8c2a090ec2ad52"
   dependencies:
     abbrev "^1.0.7"
     ansi-escapes "^1.3.0"
@@ -6735,13 +6745,11 @@ uglify-js@^2.6, uglify-js@^2.6.1:
     uglify-to-browserify "~1.0.0"
 
 uglify-js@latest:
-  version "3.0.4"
-  resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.0.4.tgz#13da18bc4ecec20d29861b4ab7b60cb9ef9a9cc0"
+  version "3.0.9"
+  resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.0.9.tgz#974c5e638f5e2348f8509f0233667caedd52d813"
   dependencies:
     commander "~2.9.0"
     source-map "~0.5.1"
-  optionalDependencies:
-    uglify-to-browserify "~1.0.0"
 
 uglify-to-browserify@~1.0.0:
   version "1.0.2"
@@ -6974,6 +6982,10 @@ vue@^2.3.3:
   version "2.3.3"
   resolved "https://registry.yarnpkg.com/vue/-/vue-2.3.3.tgz#d1eaa8fde5240735a4563e74f2c7fead9cbb064c"
 
+vuex@^2.3.1:
+  version "2.3.1"
+  resolved "https://registry.yarnpkg.com/vuex/-/vuex-2.3.1.tgz#cde8e997c1f9957719bc7dea154f9aa691d981a6"
+
 vxx@^1.2.0:
   version "1.2.2"
   resolved "https://registry.yarnpkg.com/vxx/-/vxx-1.2.2.tgz#741fb51c6f11d3383da6f9b92018a5d7ba807611"