├── static └── .gitkeep ├── src ├── assets │ ├── style │ │ ├── navbar.scss │ │ ├── _fonts.scss │ │ ├── app.scss │ │ └── _variables.scss │ └── images │ │ └── logo.png ├── store │ ├── getters.js │ ├── actions.js │ ├── index.js │ ├── plugins.js │ ├── state.js │ └── mutations.js ├── utils.js ├── services │ ├── chat.js │ ├── user.js │ └── auth.js ├── components │ ├── App.vue │ ├── AppFooter.vue │ ├── common │ │ ├── Countries.vue │ │ ├── Spinner.vue │ │ └── countries.data.js │ ├── Hello.vue │ ├── login │ │ └── Login.vue │ ├── signup │ │ └── Signup.vue │ ├── AppNav.vue │ └── dashboard │ │ ├── AddressModal.vue │ │ └── Dashboard.vue ├── main.js └── router │ └── index.js ├── .eslintignore ├── config ├── prod.env.js ├── test.env.js ├── dev.env.js └── index.js ├── .gitignore ├── test ├── unit │ ├── .eslintrc │ ├── specs │ │ └── Hello.spec.js │ ├── index.js │ └── karma.conf.js └── e2e │ ├── custom-assertions │ └── elementCount.js │ ├── specs │ └── loginTest.js │ ├── runner.js │ └── nightwatch.conf.js ├── .editorconfig ├── .postcssrc.js ├── .babelrc ├── .eslintrc.js ├── index.html ├── package.json └── README.md /static/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/style/navbar.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | build/*.js 2 | config/*.js 3 | -------------------------------------------------------------------------------- /src/store/getters.js: -------------------------------------------------------------------------------- 1 | export const user = state => state.user 2 | -------------------------------------------------------------------------------- /config/prod.env.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | NODE_ENV: '"production"' 3 | } 4 | -------------------------------------------------------------------------------- /src/assets/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Big-Silver/Vue-Chat/HEAD/src/assets/images/logo.png -------------------------------------------------------------------------------- /src/store/actions.js: -------------------------------------------------------------------------------- 1 | // Here is where you can put async operations. 2 | // See the Vuex official docs for more information. 3 | 4 | // ... 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | dist/ 4 | npm-debug.log 5 | yarn-error.log 6 | test/unit/coverage 7 | test/e2e/reports 8 | selenium-debug.log 9 | -------------------------------------------------------------------------------- /test/unit/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "mocha": true 4 | }, 5 | "globals": { 6 | "expect": true, 7 | "sinon": true 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /config/test.env.js: -------------------------------------------------------------------------------- 1 | var merge = require('webpack-merge') 2 | var devEnv = require('./dev.env') 3 | 4 | module.exports = merge(devEnv, { 5 | NODE_ENV: '"testing"' 6 | }) 7 | -------------------------------------------------------------------------------- /config/dev.env.js: -------------------------------------------------------------------------------- 1 | var merge = require('webpack-merge') 2 | var prodEnv = require('./prod.env') 3 | 4 | module.exports = merge(prodEnv, { 5 | NODE_ENV: '"development"' 6 | }) 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.postcssrc.js: -------------------------------------------------------------------------------- 1 | // https://github.com/michael-ciniawsky/postcss-load-config 2 | 3 | module.exports = { 4 | "plugins": { 5 | // to edit target browsers: use "browserlist" field in package.json 6 | "autoprefixer": {} 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["env", { "modules": false }], 4 | "stage-2" 5 | ], 6 | "plugins": ["transform-runtime"], 7 | "comments": false, 8 | "env": { 9 | "test": { 10 | "presets": ["env", "stage-2"], 11 | "plugins": [ "istanbul" ] 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/assets/style/_fonts.scss: -------------------------------------------------------------------------------- 1 | /* Font Awesome */ 2 | $fa-font-path: '../../../node_modules/font-awesome/fonts'; 3 | @import '../../../node_modules/font-awesome/scss/font-awesome'; 4 | 5 | /* Roboto */ 6 | $roboto-font-path: '../../../node_modules/roboto-fontface/fonts'; 7 | @import '../../../node_modules/roboto-fontface/css/roboto/sass/roboto-fontface'; -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | /** 4 | * Get the error from a response. 5 | * 6 | * @param {Response} response The Vue-resource Response that we will try to get errors from. 7 | */ 8 | getError: function (response) { 9 | return response.body['error_description'] 10 | ? response.body.error_description 11 | : response.statusText 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /test/unit/specs/Hello.spec.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Hello from '@/components/Hello' 3 | 4 | describe('Hello.vue', () => { 5 | it('should render correct contents', () => { 6 | const Constructor = Vue.extend(Hello) 7 | const vm = new Constructor().$mount() 8 | expect(vm.$el.querySelector('.hello h1').textContent) 9 | .to.equal('Welcome to Your Vue.js App') 10 | }) 11 | }) 12 | -------------------------------------------------------------------------------- /src/store/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuex from 'vuex' 3 | import { state } from './state' 4 | import * as getters from './getters' 5 | import * as actions from './actions' 6 | import * as mutations from './mutations' 7 | import plugins from './plugins' 8 | 9 | Vue.use(Vuex) 10 | 11 | const store = new Vuex.Store({ 12 | state, 13 | getters, 14 | actions, 15 | mutations, 16 | plugins 17 | }) 18 | 19 | export default store 20 | -------------------------------------------------------------------------------- /src/store/plugins.js: -------------------------------------------------------------------------------- 1 | import { STORAGE_KEY } from './state' 2 | 3 | const localStoragePlugin = store => { 4 | store.subscribe((mutation, state) => { 5 | const syncedData = { auth: state.auth, user: state.user } 6 | 7 | localStorage.setItem(STORAGE_KEY, JSON.stringify(syncedData)) 8 | 9 | if (mutation.type === 'CLEAR_ALL_DATA') { 10 | localStorage.removeItem(STORAGE_KEY) 11 | } 12 | }) 13 | } 14 | 15 | // TODO: setup env 16 | // export default process.env.NODE_ENV !== 'production' ? [localStoragePlugin] : [localStoragePlugin] 17 | export default [localStoragePlugin] 18 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | // http://eslint.org/docs/user-guide/configuring 2 | 3 | module.exports = { 4 | root: true, 5 | parser: 'babel-eslint', 6 | parserOptions: { 7 | sourceType: 'module' 8 | }, 9 | env: { 10 | browser: true, 11 | }, 12 | // required for eslint-config-vue 13 | extends: 'vue', 14 | // required to lint *.vue files 15 | plugins: [ 16 | 'html' 17 | ], 18 | globals: { 19 | '$': true, 20 | '_': true, 21 | 'utils': true 22 | }, 23 | // add your custom rules here 24 | 'rules': { 25 | // allow debugger during development 26 | 'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/services/chat.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | 3 | const API_URL = 'http://localhost:3000' 4 | 5 | export default { 6 | 7 | install (Vue, options) { 8 | Vue.http.interceptors.push((request, next) => { 9 | request.headers.set('Content-Type', 'application/x-www-form-urlencoded') 10 | request.headers.set('Accept', 'application/json') 11 | }) 12 | 13 | Vue.prototype.$chat = Vue.chat = this 14 | }, 15 | 16 | initMessage () { 17 | return Vue.http.get(API_URL + '/message') 18 | .then((response) => { 19 | return response 20 | }) 21 | .catch((errorResponse) => { 22 | return errorResponse 23 | }) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /test/unit/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | Vue.config.productionTip = false 3 | 4 | // Polyfill fn.bind() for PhantomJS 5 | /* eslint-disable no-extend-native */ 6 | Function.prototype.bind = require('function-bind') 7 | 8 | // require all test files (files that ends with .spec.js) 9 | const testsContext = require.context('./specs', true, /\.spec$/) 10 | testsContext.keys().forEach(testsContext) 11 | 12 | // require all src files except main.js for coverage. 13 | // you can also change this to match only the subset of files that 14 | // you want coverage for. 15 | const srcContext = require.context('../../src', true, /^\.\/(?!main(\.js)?$)/) 16 | srcContext.keys().forEach(srcContext) 17 | -------------------------------------------------------------------------------- /src/components/App.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 24 | 25 | 38 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Vue Project 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/services/user.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | 3 | import * as QueryString from 'querystring' 4 | 5 | const API_URL = 'http://localhost:3000' 6 | 7 | export default { 8 | 9 | install (Vue, options) { 10 | Vue.http.interceptors.push((request, next) => { 11 | request.headers.set('Content-Type', 'application/x-www-form-urlencoded') 12 | request.headers.set('Accept', 'application/json') 13 | }) 14 | 15 | Vue.prototype.$user = Vue.user = this 16 | }, 17 | 18 | getUsers (origin) { 19 | const data = QueryString.stringify({ 20 | 'workspaceId': origin.workspaceId 21 | }) 22 | return Vue.http.post(API_URL + '/users', data) 23 | .then((response) => { 24 | return response 25 | }) 26 | .catch((err) => { 27 | return err 28 | }) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/store/state.js: -------------------------------------------------------------------------------- 1 | // Set the key we'll use in local storage. 2 | // Go to Chrome dev tools, application tab, click "Local Storage" and "http://localhost:8080" 3 | // and you'll see this key set below (if logged in): 4 | export const STORAGE_KEY = 'example-vue-project' 5 | 6 | let syncedData = { 7 | auth: { 8 | isLoggedIn: false, 9 | accessToken: null, 10 | refreshToken: null 11 | }, 12 | user: { 13 | name: null 14 | } 15 | } 16 | 17 | const notSyncedData = { 18 | appnav: { 19 | searchText: '', 20 | searchTimestamp: null 21 | } 22 | } 23 | 24 | // Sync with local storage. 25 | if (localStorage.getItem(STORAGE_KEY)) { 26 | syncedData = JSON.parse(localStorage.getItem(STORAGE_KEY)) 27 | } 28 | 29 | // Merge data and export it. 30 | export const state = Object.assign(syncedData, notSyncedData) 31 | -------------------------------------------------------------------------------- /src/assets/style/app.scss: -------------------------------------------------------------------------------- 1 | @import 'fonts'; 2 | @import 'variables'; 3 | @import '../../../node_modules/bootstrap/dist/css/bootstrap.css'; 4 | @import '../../../node_modules/bootstrap-vue/dist/bootstrap-vue.css'; 5 | @import '../../../node_modules/bootstrap/scss/bootstrap'; 6 | @import '../../../node_modules/vue-multiselect/dist/vue-multiselect.min.css'; 7 | 8 | // Some quick styling to match the multiselct with Bootstrap. 9 | #app .multiselect__tags { 10 | border: $input-btn-border-width solid $input-border-color; 11 | } 12 | 13 | #app .multiselect, .multiselect__input, .multiselect__single { 14 | font-size: $font-size-base; 15 | } 16 | 17 | #app .multiselect--active .multiselect__tags { 18 | color: $input-color-focus; 19 | background-color: $input-bg-focus; 20 | border-color: $input-border-focus; 21 | outline: none; 22 | } 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/store/mutations.js: -------------------------------------------------------------------------------- 1 | export const UPDATE_AUTH = (state, auth) => { 2 | state.auth = auth 3 | } 4 | 5 | export const UPDATE_USER = (state, user) => { 6 | state.user = user 7 | } 8 | 9 | export const APPNAV_SEARCH = (state, searchData) => { 10 | state.appnav = searchData 11 | } 12 | 13 | /** 14 | * Clear each property, one by one, so reactivity still works. 15 | * 16 | * (ie. clear out state.auth.isLoggedIn so Navbar component automatically reacts to logged out state, 17 | * and the Navbar menu adjusts accordingly) 18 | * 19 | * TODO: use a common import of default state to reset these values with. 20 | */ 21 | export const CLEAR_ALL_DATA = (state) => { 22 | // Auth 23 | state.auth.isLoggedIn = false 24 | state.auth.accessToken = null 25 | state.auth.refreshToken = null 26 | 27 | // User 28 | state.user.name = '' 29 | } 30 | -------------------------------------------------------------------------------- /test/e2e/custom-assertions/elementCount.js: -------------------------------------------------------------------------------- 1 | // A custom Nightwatch assertion. 2 | // the name of the method is the filename. 3 | // can be used in tests like this: 4 | // 5 | // browser.assert.elementCount(selector, count) 6 | // 7 | // for how to write custom assertions see 8 | // http://nightwatchjs.org/guide#writing-custom-assertions 9 | exports.assertion = function (selector, count) { 10 | this.message = 'Testing if element <' + selector + '> has count: ' + count 11 | this.expected = count 12 | this.pass = function (val) { 13 | return val === this.expected 14 | } 15 | this.value = function (res) { 16 | return res.value 17 | } 18 | this.command = function (cb) { 19 | var self = this 20 | return this.api.execute(function (selector) { 21 | return document.querySelectorAll(selector).length 22 | }, [selector], function (res) { 23 | cb.call(self, res) 24 | }) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/components/AppFooter.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 25 | 26 | 41 | -------------------------------------------------------------------------------- /test/e2e/specs/loginTest.js: -------------------------------------------------------------------------------- 1 | // For authoring Nightwatch tests, see 2 | // http://nightwatchjs.org/guide#usage 3 | 4 | /** 5 | * Test that user can login and see dashboard. 6 | */ 7 | module.exports = { 8 | 'default e2e tests': function (browser) { 9 | // automatically uses dev Server port from /config.index.js 10 | // default: http://localhost:8080 11 | // see nightwatch.conf.js 12 | const devServer = browser.globals.devServerURL 13 | 14 | browser 15 | .url(devServer) 16 | .waitForElementVisible('#app', 5000) 17 | 18 | // Assert that user can see login. 19 | .assert.elementPresent('.ev-login') 20 | .setValue('.js-login__username', 'demouser') 21 | .setValue('.js-login__password', 'testpass') 22 | .click('.js-login__submit') 23 | .pause(3000) 24 | 25 | // Assert that user can see dashboard. 26 | .assert.containsText('.ev-dashboard__heading', 'This is the dashboard') 27 | .end() 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/components/common/Countries.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 39 | 40 | 43 | -------------------------------------------------------------------------------- /test/e2e/runner.js: -------------------------------------------------------------------------------- 1 | // 1. start the dev server using production config 2 | process.env.NODE_ENV = 'testing' 3 | var server = require('../../build/dev-server.js') 4 | 5 | // 2. run the nightwatch test suite against it 6 | // to run in additional browsers: 7 | // 1. add an entry in test/e2e/nightwatch.conf.json under "test_settings" 8 | // 2. add it to the --env flag below 9 | // or override the environment flag, for example: `npm run e2e -- --env chrome,firefox` 10 | // For more information on Nightwatch's config file, see 11 | // http://nightwatchjs.org/guide#settings-file 12 | var opts = process.argv.slice(2) 13 | if (opts.indexOf('--config') === -1) { 14 | opts = opts.concat(['--config', 'test/e2e/nightwatch.conf.js']) 15 | } 16 | if (opts.indexOf('--env') === -1) { 17 | opts = opts.concat(['--env', 'chrome']) 18 | } 19 | 20 | var spawn = require('cross-spawn') 21 | var runner = spawn('./node_modules/.bin/nightwatch', opts, { stdio: 'inherit' }) 22 | 23 | runner.on('exit', function (code) { 24 | server.close() 25 | process.exit(code) 26 | }) 27 | 28 | runner.on('error', function (err) { 29 | server.close() 30 | throw err 31 | }) 32 | -------------------------------------------------------------------------------- /test/unit/karma.conf.js: -------------------------------------------------------------------------------- 1 | // This is a karma config file. For more details see 2 | // http://karma-runner.github.io/0.13/config/configuration-file.html 3 | // we are also using it with karma-webpack 4 | // https://github.com/webpack/karma-webpack 5 | 6 | var webpackConfig = require('../../build/webpack.test.conf') 7 | 8 | module.exports = function (config) { 9 | config.set({ 10 | // to run in additional browsers: 11 | // 1. install corresponding karma launcher 12 | // http://karma-runner.github.io/0.13/config/browsers.html 13 | // 2. add it to the `browsers` array below. 14 | browsers: ['PhantomJS'], 15 | frameworks: ['mocha', 'sinon-chai'], 16 | reporters: ['spec', 'coverage'], 17 | files: [ 18 | '../../node_modules/babel-polyfill/dist/polyfill.js', 19 | './index.js' 20 | ], 21 | preprocessors: { 22 | './index.js': ['webpack', 'sourcemap'] 23 | }, 24 | webpack: webpackConfig, 25 | webpackMiddleware: { 26 | noInfo: true 27 | }, 28 | coverageReporter: { 29 | dir: './coverage', 30 | reporters: [ 31 | { type: 'lcov', subdir: '.' }, 32 | { type: 'text-summary' } 33 | ] 34 | } 35 | }) 36 | } 37 | -------------------------------------------------------------------------------- /test/e2e/nightwatch.conf.js: -------------------------------------------------------------------------------- 1 | require('babel-register') 2 | var config = require('../../config') 3 | 4 | // http://nightwatchjs.org/getingstarted#settings-file 5 | module.exports = { 6 | src_folders: ['test/e2e/specs'], 7 | output_folder: 'test/e2e/reports', 8 | custom_assertions_path: ['test/e2e/custom-assertions'], 9 | 10 | selenium: { 11 | start_process: true, 12 | server_path: require('selenium-server').path, 13 | host: '127.0.0.1', 14 | port: 4444, 15 | cli_args: { 16 | 'webdriver.chrome.driver': require('chromedriver').path 17 | } 18 | }, 19 | 20 | test_settings: { 21 | default: { 22 | selenium_port: 4444, 23 | selenium_host: 'localhost', 24 | silent: true, 25 | globals: { 26 | devServerURL: 'http://localhost:' + (process.env.PORT || config.dev.port) 27 | } 28 | }, 29 | 30 | chrome: { 31 | desiredCapabilities: { 32 | browserName: 'chrome', 33 | javascriptEnabled: true, 34 | acceptSslCerts: true 35 | } 36 | }, 37 | 38 | firefox: { 39 | desiredCapabilities: { 40 | browserName: 'firefox', 41 | javascriptEnabled: true, 42 | acceptSslCerts: true 43 | } 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | /* Twitter Bootstrap JS (this could also be handled in an app.js file) */ 2 | require('bootstrap') 3 | 4 | /* Vue */ 5 | import Vue from 'vue' 6 | import router from './router' 7 | import store from './store' 8 | import VueResource from 'vue-resource' 9 | import BootstrapVue from 'bootstrap-vue' 10 | import socketio from 'socket.io-client' 11 | import VueSocketio from 'vue-socket.io' 12 | import VueSession from 'vue-session' 13 | import VueChatScroll from 'vue-chat-scroll' 14 | import Autoscroll from 'vue-autoscroll' 15 | 16 | Vue.use(BootstrapVue) 17 | Vue.use(VueResource) 18 | Vue.use(VueSocketio, socketio('http://localhost:3000')) 19 | Vue.use(VueSession) 20 | Vue.use(VueChatScroll) 21 | Vue.use(Autoscroll) 22 | 23 | Vue.config.productionTip = false 24 | 25 | /* App sass */ 26 | import './assets/style/app.scss' 27 | 28 | /* App component */ 29 | import App from './components/App' 30 | 31 | /* Auth plugin */ 32 | import Auth from './services/auth' 33 | import Chat from './services/chat' 34 | import User from './services/user' 35 | Vue.use(Auth) 36 | Vue.use(Chat) 37 | Vue.use(User) 38 | 39 | /* eslint-disable no-new */ 40 | new Vue({ 41 | el: '#app', 42 | // Attach the Vue instance to the window, 43 | // so it's available globally. 44 | created: function () { 45 | window.Vue = this 46 | }, 47 | router, 48 | store, 49 | render: h => h(App) 50 | }) 51 | -------------------------------------------------------------------------------- /src/components/Hello.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 33 | 34 | 35 | 54 | -------------------------------------------------------------------------------- /src/router/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Router from 'vue-router' 3 | 4 | Vue.use(Router) 5 | 6 | const router = new Router({ 7 | mode: 'history', 8 | routes: [ 9 | // Each of these routes are loaded asynchronously, when a user first navigates to each corresponding endpoint. 10 | // The route will load once into memory, the first time it's called, and no more on future calls. 11 | // This behavior can be observed on the network tab of your browser dev tools. 12 | { 13 | path: '/', 14 | name: 'login', 15 | component: function (resolve) { 16 | require(['@/components/login/Login.vue'], resolve) 17 | }, 18 | beforeEnter: authRoute 19 | }, 20 | { 21 | path: '/login', 22 | name: 'login1', 23 | component: function (resolve) { 24 | require(['@/components/login/Login.vue'], resolve) 25 | }, 26 | beforeEnter: authRoute 27 | }, 28 | { 29 | path: '/signup', 30 | name: 'signup', 31 | component: function (resolve) { 32 | require(['@/components/signup/Signup.vue'], resolve) 33 | } 34 | }, 35 | { 36 | path: '/home', 37 | name: 'dashboard', 38 | component: function (resolve) { 39 | require(['@/components/dashboard/Dashboard.vue'], resolve) 40 | }, 41 | beforeEnter: guardRoute 42 | }, 43 | { 44 | path: '/*', 45 | redirect: '/login' 46 | } 47 | ] 48 | }) 49 | 50 | function guardRoute (to, from, next) { 51 | // work-around to get to the Vuex store (as of Vue 2.0) 52 | const auth = router.app.$options.store.state.auth 53 | 54 | if (!auth.isLoggedIn) { 55 | next({ 56 | path: '/login', 57 | query: { redirect: to.fullPath } 58 | }) 59 | } else { 60 | next() 61 | } 62 | } 63 | 64 | function authRoute (to, from, next) { 65 | console.log('authRoute') 66 | const auth = router.app.$options.store.state.auth 67 | 68 | if (auth.isLoggedIn) { 69 | next({ 70 | path: '/home' 71 | }) 72 | } else { 73 | next() 74 | } 75 | next() 76 | } 77 | 78 | export default router 79 | -------------------------------------------------------------------------------- /src/components/login/Login.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 75 | 76 | 82 | -------------------------------------------------------------------------------- /src/components/common/Spinner.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 37 | 38 | 93 | -------------------------------------------------------------------------------- /src/components/signup/Signup.vue: -------------------------------------------------------------------------------- 1 | 45 | 46 | 81 | 82 | -------------------------------------------------------------------------------- /config/index.js: -------------------------------------------------------------------------------- 1 | // see http://vuejs-templates.github.io/webpack for documentation. 2 | var path = require('path') 3 | 4 | module.exports = { 5 | build: { 6 | env: require('./prod.env'), 7 | index: path.resolve(__dirname, '../dist/index.html'), 8 | assetsRoot: path.resolve(__dirname, '../dist'), 9 | assetsSubDirectory: 'static', 10 | assetsPublicPath: '/', 11 | productionSourceMap: true, 12 | // Gzip off by default as many popular static hosts such as 13 | // Surge or Netlify already gzip all static assets for you. 14 | // Before setting to `true`, make sure to: 15 | // npm install --save-dev compression-webpack-plugin 16 | productionGzip: false, 17 | productionGzipExtensions: ['js', 'css'], 18 | // Run the build command with an extra argument to 19 | // View the bundle analyzer report after build finishes: 20 | // `npm run build --report` 21 | // Set to `true` or `false` to always turn it on or off 22 | bundleAnalyzerReport: process.env.npm_config_report 23 | }, 24 | dev: { 25 | env: require('./dev.env'), 26 | port: 8080, 27 | autoOpenBrowser: true, 28 | assetsSubDirectory: 'static', 29 | assetsPublicPath: '/', 30 | proxyTable: { 31 | '/auth': { 32 | // TODO: Update to use node express oauth2 server for better example. 33 | target: 'http://brentertainment.com/oauth2/lockdin/token', // <-- demo oauth2 server, https://github.com/bshaffer/oauth2-demo-php 34 | changeOrigin: true, 35 | ws: true, 36 | pathRewrite: { 37 | '^/auth': '' 38 | }, 39 | router: { 40 | } 41 | }, 42 | '/api': { 43 | target: 'http://brentertainment.com/oauth2', // api server 44 | changeOrigin: true, // needed for virtual hosted sites 45 | ws: true, // proxy websockets 46 | pathRewrite: { 47 | '^/api': '/lockdin' // rewrite path localhost:8080/api to http://brentertainment.com/oauth2/lockdin 48 | }, 49 | router: { 50 | // when request.headers.host == 'dev.localhost:3000', 51 | // override target 'http://www.example.org' to 'http://localhost:8000' 52 | // 'dev.localhost:3000': 'http://localhost:8000' 53 | } 54 | } 55 | }, 56 | // CSS Sourcemaps off by default because relative paths are "buggy" 57 | // with this option, according to the CSS-Loader README 58 | // (https://github.com/webpack/css-loader#sourcemaps) 59 | // In our experience, they generally work as expected, 60 | // just be aware of this issue when enabling this option. 61 | cssSourceMap: false 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/components/AppNav.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 69 | 70 | 85 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example-vue-project", 3 | "version": "1.0.0", 4 | "description": "A Vue.js project", 5 | "author": "David Graham ", 6 | "private": true, 7 | "scripts": { 8 | "dev": "node build/dev-server.js", 9 | "build": "node build/build.js", 10 | "unit": "cross-env BABEL_ENV=test karma start test/unit/karma.conf.js --single-run", 11 | "e2e": "node test/e2e/runner.js", 12 | "test": "npm run unit && npm run e2e", 13 | "lint": "eslint --ext .js,.vue src test/unit/specs test/e2e/specs" 14 | }, 15 | "dependencies": { 16 | "bootstrap-vue": "^2.0.0-rc.8", 17 | "popper.js": "^1.14.3", 18 | "query-string": "^6.0.0", 19 | "socket.io-client": "^2.1.0", 20 | "vue": "^2.2.2", 21 | "vue-autoscroll": "^1.1.1", 22 | "vue-chat-scroll": "^1.2.1", 23 | "vue-resource": "^1.2.1", 24 | "vue-router": "^2.2.0", 25 | "vue-session": "^1.0.0", 26 | "vue-socket.io": "^2.1.1-b", 27 | "vuex": "^2.2.1" 28 | }, 29 | "devDependencies": { 30 | "autoprefixer": "^6.7.2", 31 | "babel-core": "^6.22.1", 32 | "babel-eslint": "^7.1.1", 33 | "babel-loader": "^6.2.10", 34 | "babel-plugin-istanbul": "^3.1.2", 35 | "babel-plugin-transform-runtime": "^6.22.0", 36 | "babel-polyfill": "^6.23.0", 37 | "babel-preset-env": "^1.2.1", 38 | "babel-preset-stage-2": "^6.22.0", 39 | "babel-register": "^6.22.0", 40 | "bootstrap": "^4.0.0-alpha.6", 41 | "chai": "^3.5.0", 42 | "chalk": "^1.1.3", 43 | "chromedriver": "^2.27.2", 44 | "connect-history-api-fallback": "^1.3.0", 45 | "copy-webpack-plugin": "^4.0.1", 46 | "cross-env": "^3.1.4", 47 | "cross-spawn": "^5.0.1", 48 | "css-loader": "^0.26.1", 49 | "eslint": "^3.14.1", 50 | "eslint-config-vue": "^2.0.2", 51 | "eslint-friendly-formatter": "^2.0.7", 52 | "eslint-loader": "^1.6.1", 53 | "eslint-plugin-html": "^2.0.0", 54 | "eslint-plugin-vue": "^2.0.1", 55 | "eventsource-polyfill": "^0.9.6", 56 | "express": "^4.14.1", 57 | "extract-text-webpack-plugin": "^2.0.0", 58 | "file-loader": "^0.10.0", 59 | "font-awesome": "^4.7.0", 60 | "friendly-errors-webpack-plugin": "^1.1.3", 61 | "function-bind": "^1.1.0", 62 | "html-webpack-plugin": "^2.28.0", 63 | "http-proxy-middleware": "^0.17.3", 64 | "inject-loader": "^2.0.1", 65 | "jquery": "^3.2.0", 66 | "karma": "^1.4.1", 67 | "karma-coverage": "^1.1.1", 68 | "karma-mocha": "^1.3.0", 69 | "karma-phantomjs-launcher": "^1.0.2", 70 | "karma-sinon-chai": "^1.2.4", 71 | "karma-sourcemap-loader": "^0.3.7", 72 | "karma-spec-reporter": "0.0.26", 73 | "karma-webpack": "^2.0.2", 74 | "lodash": "^4.17.4", 75 | "lolex": "^1.5.2", 76 | "mocha": "^3.2.0", 77 | "nightwatch": "^0.9.12", 78 | "node-sass": "^4.5.0", 79 | "opn": "^4.0.2", 80 | "optimize-css-assets-webpack-plugin": "^1.3.0", 81 | "ora": "^1.1.0", 82 | "phantomjs-prebuilt": "^2.1.14", 83 | "rimraf": "^2.6.0", 84 | "roboto-fontface": "^0.7.0", 85 | "sass-loader": "^6.0.3", 86 | "selenium-server": "^3.0.1", 87 | "semver": "^5.3.0", 88 | "sinon": "^1.17.7", 89 | "sinon-chai": "^2.8.0", 90 | "stylus": "^0.54.5", 91 | "stylus-loader": "^3.0.1", 92 | "tether": "^1.4.0", 93 | "url-loader": "^0.5.7", 94 | "vue-loader": "^11.1.4", 95 | "vue-multiselect": "^2.0.0-beta.15", 96 | "vue-style-loader": "^2.0.0", 97 | "vue-template-compiler": "^2.2.4", 98 | "webpack": "^2.2.1", 99 | "webpack-bundle-analyzer": "^2.2.1", 100 | "webpack-dev-middleware": "^1.10.0", 101 | "webpack-hot-middleware": "^2.16.1", 102 | "webpack-merge": "^2.6.1" 103 | }, 104 | "engines": { 105 | "node": ">= 4.0.0", 106 | "npm": ">= 3.0.0" 107 | }, 108 | "browserslist": [ 109 | "> 1%", 110 | "last 2 versions", 111 | "not ie <= 8" 112 | ] 113 | } 114 | -------------------------------------------------------------------------------- /src/components/dashboard/AddressModal.vue: -------------------------------------------------------------------------------- 1 | 50 | 51 | 208 | 209 | 212 | -------------------------------------------------------------------------------- /src/components/dashboard/Dashboard.vue: -------------------------------------------------------------------------------- 1 | 50 | 51 | 171 | 172 | 225 | -------------------------------------------------------------------------------- /src/services/auth.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import router from '../router' 3 | import store from '../store' 4 | 5 | import * as QueryString from 'querystring' 6 | 7 | /** 8 | * @var{string} LOGIN_URL The endpoint for logging in. This endpoint should be proxied by Webpack dev server 9 | * and maybe nginx in production (cleaner calls and avoids CORS issues). 10 | */ 11 | const API_URL = 'http://localhost:3000' 12 | 13 | /** 14 | * @var{string} REFRESH_TOKEN_URL The endpoint for refreshing an access_token. This endpoint should be proxied 15 | * by Webpack dev server and maybe nginx in production (cleaner calls and avoids CORS issues). 16 | */ 17 | const REFRESH_TOKEN_URL = '/auth' 18 | 19 | /** 20 | * TODO: This is here to demonstrate what an OAuth server will want. Ultimately you don't want to 21 | * expose a client_secret here. You want your real project backend to take a username/password 22 | * request and add the client secret on the server-side and forward that request 23 | * onto an OAuth server. Your backend acts as a middle-man in the process, which is better, for 24 | * example in situations like DDoS attacks. 25 | * 26 | * @var{Object} AUTH_BASIC_HEADERS The options to pass into a Vue-resource http call. Includes 27 | * the headers used for login and token refresh and emulateJSON flag since we are hitting an 28 | * OAuth server directly that can't handle application/json. 29 | */ 30 | const AUTH_BASIC_HEADERS = { 31 | headers: { 32 | 'Authorization': 'Basic ZGVtb2FwcDpkZW1vcGFzcw==' // Base64(client_id:client_secret) "demoapp:demopass" 33 | }, 34 | emulateJSON: true 35 | } 36 | 37 | /** 38 | * Auth Plugin 39 | * 40 | * (see https://vuejs.org/v2/guide/plugins.html for more info on Vue.js plugins) 41 | * 42 | * Handles login and token authentication using OAuth2. 43 | */ 44 | export default { 45 | 46 | /** 47 | * Install the Auth class. 48 | * 49 | * Creates a Vue-resource http interceptor to handle automatically adding auth headers 50 | * and refreshing tokens. Then attaches this object to the global Vue (as Vue.auth). 51 | * 52 | * @param {Object} Vue The global Vue. 53 | * @param {Object} options Any options we want to have in our plugin. 54 | * @return {void} 55 | */ 56 | install (Vue, options) { 57 | /* 58 | ************* Token ************** 59 | 60 | Vue.http.interceptors.push((request, next) => { 61 | const token = store.state.auth.accessToken 62 | const hasAuthHeader = request.headers.has('Authorization') 63 | console.log(store.state) 64 | if (token && !hasAuthHeader) { 65 | this.setAuthHeader(request) 66 | } 67 | 68 | next((response) => { 69 | if (this._isInvalidToken(response)) { 70 | return this._refreshToken(request) 71 | } 72 | }) 73 | }) 74 | */ 75 | 76 | Vue.http.interceptors.push((request, next) => { 77 | request.headers.set('Content-Type', 'application/x-www-form-urlencoded') 78 | request.headers.set('Accept', 'application/json') 79 | }) 80 | 81 | Vue.prototype.$auth = Vue.auth = this 82 | }, 83 | 84 | /** 85 | * Login 86 | * 87 | * @param {Object.} creds The username and password for logging in. 88 | * @param {string|null} redirect The name of the Route to redirect to. 89 | * @return {Promise} 90 | */ 91 | login (creds, redirect) { 92 | // const params = { 'grant_type': 'password', 'email': creds.username, 'password': creds.password } 93 | const data = QueryString.stringify({ 94 | 'workspaceId': creds.workspaceId, 95 | 'email': creds.email, 96 | 'password': creds.password 97 | }) 98 | return Vue.http.post(API_URL + '/login', data) 99 | .then((response) => { 100 | this._storeToken(response) 101 | if (redirect) { 102 | router.push({ name: redirect }) 103 | } 104 | 105 | return response 106 | }) 107 | .catch((errorResponse) => { 108 | return errorResponse 109 | }) 110 | }, 111 | 112 | register (creds, redirect) { 113 | const data = QueryString.stringify({ 114 | 'name': creds.name, 115 | 'workspace': creds.workspace, 116 | 'email': creds.email, 117 | 'password': creds.password 118 | }) 119 | return Vue.http.post(API_URL + '/register', data) 120 | .then((response) => { 121 | this._storeToken(response) 122 | if (redirect) { 123 | router.push({ name: redirect }) 124 | } 125 | }) 126 | .catch((err) => { 127 | return err 128 | }) 129 | }, 130 | /** 131 | * Logout 132 | * 133 | * Clear all data in our Vuex store (which resets logged-in status) and redirect back 134 | * to login form. 135 | * 136 | * @return {void} 137 | */ 138 | logout () { 139 | store.commit('CLEAR_ALL_DATA') 140 | router.push({ name: 'login' }) 141 | }, 142 | 143 | /** 144 | * Set the Authorization header on a Vue-resource Request. 145 | * 146 | * @param {Request} request The Vue-Resource Request instance to set the header on. 147 | * @return {void} 148 | */ 149 | setAuthHeader (request) { 150 | request.headers.set('Authorization', 'Bearer ' + store.state.auth.accessToken) 151 | // The demo Oauth2 server we are using requires this param, but normally you only set the header. 152 | /* eslint-disable camelcase */ 153 | request.params.access_token = store.state.auth.accessToken 154 | }, 155 | 156 | /** 157 | * Retry the original request. 158 | * 159 | * Let's retry the user's original target request that had recieved a invalid token response 160 | * (which we fixed with a token refresh). 161 | * 162 | * @param {Request} request The Vue-resource Request instance to use to repeat an http call. 163 | * @return {Promise} 164 | */ 165 | _retry (request) { 166 | this.setAuthHeader(request) 167 | 168 | return Vue.http(request) 169 | .then((response) => { 170 | return response 171 | }) 172 | .catch((response) => { 173 | return response 174 | }) 175 | }, 176 | 177 | /** 178 | * Refresh the access token 179 | * 180 | * Make an ajax call to the OAuth2 server to refresh the access token (using our refresh token). 181 | * 182 | * @private 183 | * @param {Request} request Vue-resource Request instance, the original request that we'll retry. 184 | * @return {Promise} 185 | */ 186 | _refreshToken (request) { 187 | const params = { 'grant_type': 'refresh_token', 'refresh_token': store.state.auth.refreshToken } 188 | 189 | return Vue.http.post(REFRESH_TOKEN_URL, params, AUTH_BASIC_HEADERS) 190 | .then((result) => { 191 | this._storeToken(result) 192 | return this._retry(request) 193 | }) 194 | .catch((errorResponse) => { 195 | if (this._isInvalidToken(errorResponse)) { 196 | this.logout() 197 | } 198 | return errorResponse 199 | }) 200 | }, 201 | 202 | /** 203 | * Store tokens 204 | * 205 | * Update the Vuex store with the access/refresh tokens received from the response from 206 | * the Oauth2 server. 207 | * 208 | * @private 209 | * @param {Response} response Vue-resource Response instance from an OAuth2 server. 210 | * that contains our tokens. 211 | * @return {void} 212 | */ 213 | _storeToken (response) { 214 | const auth = store.state.auth 215 | const user = store.state.user 216 | 217 | auth.isLoggedIn = true 218 | auth.accessToken = response.body.access_token 219 | auth.refreshToken = response.body.refresh_token 220 | // TODO: get user's name from response from Oauth server. 221 | user.name = 'John Smith' 222 | 223 | store.commit('UPDATE_AUTH', auth) 224 | store.commit('UPDATE_USER', user) 225 | }, 226 | 227 | /** 228 | * Check if the Vue-resource Response is an invalid token response. 229 | * 230 | * @private 231 | * @param {Response} response The Vue-resource Response instance received from an http call. 232 | * @return {boolean} 233 | */ 234 | _isInvalidToken (response) { 235 | const status = response.status 236 | const error = response.data.error 237 | 238 | return (status === 401 && (error === 'invalid_token' || error === 'expired_token')) 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /src/components/common/countries.data.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | { value: '', text: 'Select a country' }, 3 | { value: 'Afghanistan', text: 'Afghanistan' }, 4 | { value: 'Åland Islands', text: 'Åland Islands' }, 5 | { value: 'Albania', text: 'Albania' }, 6 | { value: 'Algeria', text: 'Algeria' }, 7 | { value: 'American Samoa', text: 'American Samoa' }, 8 | { value: 'Andorra', text: 'Andorra' }, 9 | { value: 'Angola', text: 'Angola' }, 10 | { value: 'Anguilla', text: 'Anguilla' }, 11 | { value: 'Antarctica', text: 'Antarctica' }, 12 | { value: 'Antigua and Barbuda', text: 'Antigua and Barbuda' }, 13 | { value: 'Argentina', text: 'Argentina' }, 14 | { value: 'Armenia', text: 'Armenia' }, 15 | { value: 'Aruba', text: 'Aruba' }, 16 | { value: 'Australia', text: 'Australia' }, 17 | { value: 'Austria', text: 'Austria' }, 18 | { value: 'Azerbaijan', text: 'Azerbaijan' }, 19 | { value: 'Bahamas', text: 'Bahamas' }, 20 | { value: 'Bahrain', text: 'Bahrain' }, 21 | { value: 'Bangladesh', text: 'Bangladesh' }, 22 | { value: 'Barbados', text: 'Barbados' }, 23 | { value: 'Belarus', text: 'Belarus' }, 24 | { value: 'Belgium', text: 'Belgium' }, 25 | { value: 'Belize', text: 'Belize' }, 26 | { value: 'Benin', text: 'Benin' }, 27 | { value: 'Bermuda', text: 'Bermuda' }, 28 | { value: 'Bhutan', text: 'Bhutan' }, 29 | { value: 'Bolivia', text: 'Bolivia' }, 30 | { value: 'Bosnia and Herzegovina', text: 'Bosnia and Herzegovina' }, 31 | { value: 'Botswana', text: 'Botswana' }, 32 | { value: 'Bouvet Island', text: 'Bouvet Island' }, 33 | { value: 'Brazil', text: 'Brazil' }, 34 | { value: 'British Indian Ocean Territory', text: 'British Indian Ocean Territory' }, 35 | { value: 'Brunei Darussalam', text: 'Brunei Darussalam' }, 36 | { value: 'Bulgaria', text: 'Bulgaria' }, 37 | { value: 'Burkina Faso', text: 'Burkina Faso' }, 38 | { value: 'Burundi', text: 'Burundi' }, 39 | { value: 'Cambodia', text: 'Cambodia' }, 40 | { value: 'Cameroon', text: 'Cameroon' }, 41 | { value: 'Canada', text: 'Canada' }, 42 | { value: 'Cape Verde', text: 'Cape Verde' }, 43 | { value: 'Cayman Islands', text: 'Cayman Islands' }, 44 | { value: 'Central African Republic', text: 'Central African Republic' }, 45 | { value: 'Chad', text: 'Chad' }, 46 | { value: 'Chile', text: 'Chile' }, 47 | { value: 'China', text: 'China' }, 48 | { value: 'Christmas Island', text: 'Christmas Island' }, 49 | { value: 'Cocos (Keeling) Islands', text: 'Cocos (Keeling) Islands' }, 50 | { value: 'Colombia', text: 'Colombia' }, 51 | { value: 'Comoros', text: 'Comoros' }, 52 | { value: 'Congo', text: 'Congo' }, 53 | { value: 'Congo, The Democratic Republic of The', text: 'Congo, The Democratic Republic of The' }, 54 | { value: 'Cook Islands', text: 'Cook Islands' }, 55 | { value: 'Costa Rica', text: 'Costa Rica' }, 56 | { value: 'Cote D\'ivoire', text: 'Cote D\'ivoire' }, 57 | { value: 'Croatia', text: 'Croatia' }, 58 | { value: 'Cuba', text: 'Cuba' }, 59 | { value: 'Cyprus', text: 'Cyprus' }, 60 | { value: 'Czech Republic', text: 'Czech Republic' }, 61 | { value: 'Denmark', text: 'Denmark' }, 62 | { value: 'Djibouti', text: 'Djibouti' }, 63 | { value: 'Dominica', text: 'Dominica' }, 64 | { value: 'Dominican Republic', text: 'Dominican Republic' }, 65 | { value: 'Ecuador', text: 'Ecuador' }, 66 | { value: 'Egypt', text: 'Egypt' }, 67 | { value: 'El Salvador', text: 'El Salvador' }, 68 | { value: 'Equatorial Guinea', text: 'Equatorial Guinea' }, 69 | { value: 'Eritrea', text: 'Eritrea' }, 70 | { value: 'Estonia', text: 'Estonia' }, 71 | { value: 'Ethiopia', text: 'Ethiopia' }, 72 | { value: 'Falkland Islands (Malvinas)', text: 'Falkland Islands (Malvinas)' }, 73 | { value: 'Faroe Islands', text: 'Faroe Islands' }, 74 | { value: 'Fiji', text: 'Fiji' }, 75 | { value: 'Finland', text: 'Finland' }, 76 | { value: 'France', text: 'France' }, 77 | { value: 'French Guiana', text: 'French Guiana' }, 78 | { value: 'French Polynesia', text: 'French Polynesia' }, 79 | { value: 'French Southern Territories', text: 'French Southern Territories' }, 80 | { value: 'Gabon', text: 'Gabon' }, 81 | { value: 'Gambia', text: 'Gambia' }, 82 | { value: 'Georgia', text: 'Georgia' }, 83 | { value: 'Germany', text: 'Germany' }, 84 | { value: 'Ghana', text: 'Ghana' }, 85 | { value: 'Gibraltar', text: 'Gibraltar' }, 86 | { value: 'Greece', text: 'Greece' }, 87 | { value: 'Greenland', text: 'Greenland' }, 88 | { value: 'Grenada', text: 'Grenada' }, 89 | { value: 'Guadeloupe', text: 'Guadeloupe' }, 90 | { value: 'Guam', text: 'Guam' }, 91 | { value: 'Guatemala', text: 'Guatemala' }, 92 | { value: 'Guernsey', text: 'Guernsey' }, 93 | { value: 'Guinea', text: 'Guinea' }, 94 | { value: 'Guinea-bissau', text: 'Guinea-bissau' }, 95 | { value: 'Guyana', text: 'Guyana' }, 96 | { value: 'Haiti', text: 'Haiti' }, 97 | { value: 'Heard Island and Mcdonald Islands', text: 'Heard Island and Mcdonald Islands' }, 98 | { value: 'Holy See (Vatican City State)', text: 'Holy See (Vatican City State)' }, 99 | { value: 'Honduras', text: 'Honduras' }, 100 | { value: 'Hong Kong', text: 'Hong Kong' }, 101 | { value: 'Hungary', text: 'Hungary' }, 102 | { value: 'Iceland', text: 'Iceland' }, 103 | { value: 'India', text: 'India' }, 104 | { value: 'Indonesia', text: 'Indonesia' }, 105 | { value: 'Iran, Islamic Republic of', text: 'Iran, Islamic Republic of' }, 106 | { value: 'Iraq', text: 'Iraq' }, 107 | { value: 'Ireland', text: 'Ireland' }, 108 | { value: 'Isle of Man', text: 'Isle of Man' }, 109 | { value: 'Israel', text: 'Israel' }, 110 | { value: 'Italy', text: 'Italy' }, 111 | { value: 'Jamaica', text: 'Jamaica' }, 112 | { value: 'Japan', text: 'Japan' }, 113 | { value: 'Jersey', text: 'Jersey' }, 114 | { value: 'Jordan', text: 'Jordan' }, 115 | { value: 'Kazakhstan', text: 'Kazakhstan' }, 116 | { value: 'Kenya', text: 'Kenya' }, 117 | { value: 'Kiribati', text: 'Kiribati' }, 118 | { value: 'Korea, Democratic People\'s Republic of', text: 'Korea, Democratic People\'s Republic of' }, 119 | { value: 'Korea, Republic of', text: 'Korea, Republic of' }, 120 | { value: 'Kuwait', text: 'Kuwait' }, 121 | { value: 'Kyrgyzstan', text: 'Kyrgyzstan' }, 122 | { value: 'Lao People\'s Democratic Republic', text: 'Lao People\'s Democratic Republic' }, 123 | { value: 'Latvia', text: 'Latvia' }, 124 | { value: 'Lebanon', text: 'Lebanon' }, 125 | { value: 'Lesotho', text: 'Lesotho' }, 126 | { value: 'Liberia', text: 'Liberia' }, 127 | { value: 'Libyan Arab Jamahiriya', text: 'Libyan Arab Jamahiriya' }, 128 | { value: 'Liechtenstein', text: 'Liechtenstein' }, 129 | { value: 'Lithuania', text: 'Lithuania' }, 130 | { value: 'Luxembourg', text: 'Luxembourg' }, 131 | { value: 'Macao', text: 'Macao' }, 132 | { value: 'Macedonia, The Former Yugoslav Republic of', text: 'Macedonia, The Former Yugoslav Republic of' }, 133 | { value: 'Madagascar', text: 'Madagascar' }, 134 | { value: 'Malawi', text: 'Malawi' }, 135 | { value: 'Malaysia', text: 'Malaysia' }, 136 | { value: 'Maldives', text: 'Maldives' }, 137 | { value: 'Mali', text: 'Mali' }, 138 | { value: 'Malta', text: 'Malta' }, 139 | { value: 'Marshall Islands', text: 'Marshall Islands' }, 140 | { value: 'Martinique', text: 'Martinique' }, 141 | { value: 'Mauritania', text: 'Mauritania' }, 142 | { value: 'Mauritius', text: 'Mauritius' }, 143 | { value: 'Mayotte', text: 'Mayotte' }, 144 | { value: 'Mexico', text: 'Mexico' }, 145 | { value: 'Micronesia, Federated States of', text: 'Micronesia, Federated States of' }, 146 | { value: 'Moldova, Republic of', text: 'Moldova, Republic of' }, 147 | { value: 'Monaco', text: 'Monaco' }, 148 | { value: 'Mongolia', text: 'Mongolia' }, 149 | { value: 'Montenegro', text: 'Montenegro' }, 150 | { value: 'Montserrat', text: 'Montserrat' }, 151 | { value: 'Morocco', text: 'Morocco' }, 152 | { value: 'Mozambique', text: 'Mozambique' }, 153 | { value: 'Myanmar', text: 'Myanmar' }, 154 | { value: 'Namibia', text: 'Namibia' }, 155 | { value: 'Nauru', text: 'Nauru' }, 156 | { value: 'Nepal', text: 'Nepal' }, 157 | { value: 'Netherlands', text: 'Netherlands' }, 158 | { value: 'Netherlands Antilles', text: 'Netherlands Antilles' }, 159 | { value: 'New Caledonia', text: 'New Caledonia' }, 160 | { value: 'New Zealand', text: 'New Zealand' }, 161 | { value: 'Nicaragua', text: 'Nicaragua' }, 162 | { value: 'Niger', text: 'Niger' }, 163 | { value: 'Nigeria', text: 'Nigeria' }, 164 | { value: 'Niue', text: 'Niue' }, 165 | { value: 'Norfolk Island', text: 'Norfolk Island' }, 166 | { value: 'Northern Mariana Islands', text: 'Northern Mariana Islands' }, 167 | { value: 'Norway', text: 'Norway' }, 168 | { value: 'Oman', text: 'Oman' }, 169 | { value: 'Pakistan', text: 'Pakistan' }, 170 | { value: 'Palau', text: 'Palau' }, 171 | { value: 'Palestinian Territory, Occupied', text: 'Palestinian Territory, Occupied' }, 172 | { value: 'Panama', text: 'Panama' }, 173 | { value: 'Papua New Guinea', text: 'Papua New Guinea' }, 174 | { value: 'Paraguay', text: 'Paraguay' }, 175 | { value: 'Peru', text: 'Peru' }, 176 | { value: 'Philippines', text: 'Philippines' }, 177 | { value: 'Pitcairn', text: 'Pitcairn' }, 178 | { value: 'Poland', text: 'Poland' }, 179 | { value: 'Portugal', text: 'Portugal' }, 180 | { value: 'Puerto Rico', text: 'Puerto Rico' }, 181 | { value: 'Qatar', text: 'Qatar' }, 182 | { value: 'Reunion', text: 'Reunion' }, 183 | { value: 'Romania', text: 'Romania' }, 184 | { value: 'Russian Federation', text: 'Russian Federation' }, 185 | { value: 'Rwanda', text: 'Rwanda' }, 186 | { value: 'Saint Helena', text: 'Saint Helena' }, 187 | { value: 'Saint Kitts and Nevis', text: 'Saint Kitts and Nevis' }, 188 | { value: 'Saint Lucia', text: 'Saint Lucia' }, 189 | { value: 'Saint Pierre and Miquelon', text: 'Saint Pierre and Miquelon' }, 190 | { value: 'Saint Vincent and The Grenadines', text: 'Saint Vincent and The Grenadines' }, 191 | { value: 'Samoa', text: 'Samoa' }, 192 | { value: 'San Marino', text: 'San Marino' }, 193 | { value: 'Sao Tome and Principe', text: 'Sao Tome and Principe' }, 194 | { value: 'Saudi Arabia', text: 'Saudi Arabia' }, 195 | { value: 'Senegal', text: 'Senegal' }, 196 | { value: 'Serbia', text: 'Serbia' }, 197 | { value: 'Seychelles', text: 'Seychelles' }, 198 | { value: 'Sierra Leone', text: 'Sierra Leone' }, 199 | { value: 'Singapore', text: 'Singapore' }, 200 | { value: 'Slovakia', text: 'Slovakia' }, 201 | { value: 'Slovenia', text: 'Slovenia' }, 202 | { value: 'Solomon Islands', text: 'Solomon Islands' }, 203 | { value: 'Somalia', text: 'Somalia' }, 204 | { value: 'South Africa', text: 'South Africa' }, 205 | { value: 'South Georgia and The South Sandwich Islands', text: 'South Georgia and The South Sandwich Islands' }, 206 | { value: 'Spain', text: 'Spain' }, 207 | { value: 'Sri Lanka', text: 'Sri Lanka' }, 208 | { value: 'Sudan', text: 'Sudan' }, 209 | { value: 'Suriname', text: 'Suriname' }, 210 | { value: 'Svalbard and Jan Mayen', text: 'Svalbard and Jan Mayen' }, 211 | { value: 'Swaziland', text: 'Swaziland' }, 212 | { value: 'Sweden', text: 'Sweden' }, 213 | { value: 'Switzerland', text: 'Switzerland' }, 214 | { value: 'Syrian Arab Republic', text: 'Syrian Arab Republic' }, 215 | { value: 'Taiwan, Province of China', text: 'Taiwan, Province of China' }, 216 | { value: 'Tajikistan', text: 'Tajikistan' }, 217 | { value: 'Tanzania, United Republic of', text: 'Tanzania, United Republic of' }, 218 | { value: 'Thailand', text: 'Thailand' }, 219 | { value: 'Timor-leste', text: 'Timor-leste' }, 220 | { value: 'Togo', text: 'Togo' }, 221 | { value: 'Tokelau', text: 'Tokelau' }, 222 | { value: 'Tonga', text: 'Tonga' }, 223 | { value: 'Trinidad and Tobago', text: 'Trinidad and Tobago' }, 224 | { value: 'Tunisia', text: 'Tunisia' }, 225 | { value: 'Turkey', text: 'Turkey' }, 226 | { value: 'Turkmenistan', text: 'Turkmenistan' }, 227 | { value: 'Turks and Caicos Islands', text: 'Turks and Caicos Islands' }, 228 | { value: 'Tuvalu', text: 'Tuvalu' }, 229 | { value: 'Uganda', text: 'Uganda' }, 230 | { value: 'Ukraine', text: 'Ukraine' }, 231 | { value: 'United Arab Emirates', text: 'United Arab Emirates' }, 232 | { value: 'United Kingdom', text: 'United Kingdom' }, 233 | { value: 'United States', text: 'United States (USA)' }, 234 | { value: 'United States Minor Outlying Islands', text: 'United States Minor Outlying Islands' }, 235 | { value: 'Uruguay', text: 'Uruguay' }, 236 | { value: 'Uzbekistan', text: 'Uzbekistan' }, 237 | { value: 'Vanuatu', text: 'Vanuatu' }, 238 | { value: 'Venezuela', text: 'Venezuela' }, 239 | { value: 'Viet Nam', text: 'Viet Nam' }, 240 | { value: 'Virgin Islands, British', text: 'Virgin Islands, British' }, 241 | { value: 'Virgin Islands, U.S.', text: 'Virgin Islands, U.S.' }, 242 | { value: 'Wallis and Futuna', text: 'Wallis and Futuna' }, 243 | { value: 'Western Sahara', text: 'Western Sahara' }, 244 | { value: 'Yemen', text: 'Yemen' }, 245 | { value: 'Zambia', text: 'Zambia' }, 246 | { value: 'Zimbabwe', text: 'Zimbabwe' } 247 | ] 248 | -------------------------------------------------------------------------------- /src/assets/style/_variables.scss: -------------------------------------------------------------------------------- 1 | // Variables 2 | // 3 | // Copy settings from this file into the provided `_custom.scss` to override 4 | // the Bootstrap defaults without modifying key, versioned files. 5 | 6 | 7 | // Table of Contents 8 | // 9 | // Colors 10 | // Options 11 | // Spacing 12 | // Body 13 | // Links 14 | // Grid breakpoints 15 | // Grid containers 16 | // Grid columns 17 | // Fonts 18 | // Components 19 | // Tables 20 | // Buttons 21 | // Forms 22 | // Dropdowns 23 | // Z-index master list 24 | // Navbar 25 | // Navs 26 | // Pagination 27 | // Jumbotron 28 | // Form states and alerts 29 | // Cards 30 | // Tooltips 31 | // Popovers 32 | // Badges 33 | // Modals 34 | // Alerts 35 | // Progress bars 36 | // List group 37 | // Image thumbnails 38 | // Figures 39 | // Breadcrumbs 40 | // Carousel 41 | // Close 42 | // Code 43 | 44 | @mixin _assert-ascending($map, $map-name) { 45 | $prev-key: null; 46 | $prev-num: null; 47 | @each $key, $num in $map { 48 | @if $prev-num == null { 49 | // Do nothing 50 | } @else if not comparable($prev-num, $num) { 51 | @warn "Potentially invalid value for #{$map-name}: This map must be in ascending order, but key '#{$key}' has value #{$num} whose unit makes it incomparable to #{$prev-num}, the value of the previous key '#{$prev-key}' !"; 52 | } @else if $prev-num >= $num { 53 | @warn "Invalid value for #{$map-name}: This map must be in ascending order, but key '#{$key}' has value #{$num} which isn't greater than #{$prev-num}, the value of the previous key '#{$prev-key}' !"; 54 | } 55 | $prev-key: $key; 56 | $prev-num: $num; 57 | } 58 | } 59 | 60 | // Replace `$search` with `$replace` in `$string` 61 | // @author Hugo Giraudel 62 | // @param {String} $string - Initial string 63 | // @param {String} $search - Substring to replace 64 | // @param {String} $replace ('') - New value 65 | // @return {String} - Updated string 66 | @function str-replace($string, $search, $replace: "") { 67 | $index: str-index($string, $search); 68 | 69 | @if $index { 70 | @return str-slice($string, 1, $index - 1) + $replace + str-replace(str-slice($string, $index + str-length($search)), $search, $replace); 71 | } 72 | 73 | @return $string; 74 | } 75 | 76 | @mixin _assert-starts-at-zero($map) { 77 | $values: map-values($map); 78 | $first-value: nth($values, 1); 79 | @if $first-value != 0 { 80 | @warn "First breakpoint in `$grid-breakpoints` must start at 0, but starts at #{$first-value}."; 81 | } 82 | } 83 | 84 | 85 | // General variable structure 86 | // 87 | // Variable format should follow the `$component-modifier-state-property` order. 88 | 89 | 90 | // Colors 91 | // 92 | // Grayscale and brand colors for use across Bootstrap. 93 | 94 | // Start with assigning color names to specific hex values. 95 | $white: #fff !default; 96 | $black: #000 !default; 97 | $red: #d9534f !default; 98 | $orange: #f0ad4e !default; 99 | $yellow: #ffd500 !default; 100 | $green: #5cb85c !default; 101 | $blue: #0275d8 !default; 102 | $teal: #5bc0de !default; 103 | $pink: #ff5b77 !default; 104 | $purple: #613d7c !default; 105 | 106 | // Create grayscale 107 | $gray-dark: #292b2c !default; 108 | $gray: #464a4c !default; 109 | $gray-light: #636c72 !default; 110 | $gray-lighter: #eceeef !default; 111 | $gray-lightest: #f7f7f9 !default; 112 | 113 | // Reassign color vars to semantic color scheme 114 | $brand-primary: $blue !default; 115 | $brand-success: $green !default; 116 | $brand-info: $teal !default; 117 | $brand-warning: $orange !default; 118 | $brand-danger: $red !default; 119 | $brand-inverse: $gray-dark !default; 120 | 121 | 122 | // Options 123 | // 124 | // Quickly modify global styling by enabling or disabling optional features. 125 | 126 | $enable-rounded: true !default; 127 | $enable-shadows: false !default; 128 | $enable-gradients: false !default; 129 | $enable-transitions: true !default; 130 | $enable-hover-media-query: false !default; 131 | $enable-grid-classes: true !default; 132 | $enable-print-styles: true !default; 133 | 134 | 135 | // Spacing 136 | // 137 | // Control the default styling of most Bootstrap elements by modifying these 138 | // variables. Mostly focused on spacing. 139 | // You can add more entries to the $spacers map, should you need more variation. 140 | 141 | $spacer: 1rem !default; 142 | $spacer-x: $spacer !default; 143 | $spacer-y: $spacer !default; 144 | /*$spacers: ( 145 | 0: ( 146 | x: 0, 147 | y: 0 148 | ), 149 | 1: ( 150 | x: ($spacer-x * .25), 151 | y: ($spacer-y * .25) 152 | ), 153 | 2: ( 154 | x: ($spacer-x * .5), 155 | y: ($spacer-y * .5) 156 | ), 157 | 3: ( 158 | x: $spacer-x, 159 | y: $spacer-y 160 | ), 161 | 4: ( 162 | x: ($spacer-x * 1.5), 163 | y: ($spacer-y * 1.5) 164 | ), 165 | 5: ( 166 | x: ($spacer-x * 3), 167 | y: ($spacer-y * 3) 168 | ) 169 | ) !default;*/ 170 | $border-width: 1px !default; 171 | 172 | // This variable affects the `.h-*` and `.w-*` classes. 173 | $sizes: ( 174 | 25: 25%, 175 | 50: 50%, 176 | 75: 75%, 177 | 100: 100% 178 | ) !default; 179 | 180 | // Body 181 | // 182 | // Settings for the `` element. 183 | 184 | $body-bg: $white !default; 185 | $body-color: $gray-dark !default; 186 | $inverse-bg: $gray-dark !default; 187 | $inverse-color: $gray-lighter !default; 188 | 189 | 190 | // Links 191 | // 192 | // Style anchor elements. 193 | 194 | $link-color: $brand-primary !default; 195 | $link-decoration: none !default; 196 | $link-hover-color: darken($link-color, 15%) !default; 197 | $link-hover-decoration: underline !default; 198 | 199 | 200 | // Grid breakpoints 201 | // 202 | // Define the minimum dimensions at which your layout will change, 203 | // adapting to different screen sizes, for use in media queries. 204 | 205 | $grid-breakpoints: ( 206 | xs: 0, 207 | sm: 576px, 208 | md: 768px, 209 | lg: 992px, 210 | xl: 1200px 211 | ) !default; 212 | @include _assert-ascending($grid-breakpoints, "$grid-breakpoints"); 213 | @include _assert-starts-at-zero($grid-breakpoints); 214 | 215 | 216 | // Grid containers 217 | // 218 | // Define the maximum width of `.container` for different screen sizes. 219 | 220 | $container-max-widths: ( 221 | sm: 540px, 222 | md: 720px, 223 | lg: 960px, 224 | xl: 1140px 225 | ) !default; 226 | @include _assert-ascending($container-max-widths, "$container-max-widths"); 227 | 228 | 229 | // Grid columns 230 | // 231 | // Set the number of columns and specify the width of the gutters. 232 | 233 | $grid-columns: 12 !default; 234 | $grid-gutter-width-base: 30px !default; 235 | $grid-gutter-widths: ( 236 | xs: $grid-gutter-width-base, 237 | sm: $grid-gutter-width-base, 238 | md: $grid-gutter-width-base, 239 | lg: $grid-gutter-width-base, 240 | xl: $grid-gutter-width-base 241 | ) !default; 242 | 243 | // Fonts 244 | // 245 | // Font, line-height, and color for body text, headings, and more. 246 | 247 | $font-family-sans-serif: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif !default; 248 | $font-family-serif: Georgia, "Times New Roman", Times, serif !default; 249 | $font-family-monospace: Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !default; 250 | $font-family-base: $font-family-sans-serif !default; 251 | 252 | $font-size-base: 1rem !default; // Assumes the browser default, typically `16px` 253 | $font-size-lg: 1.25rem !default; 254 | $font-size-sm: .875rem !default; 255 | $font-size-xs: .75rem !default; 256 | 257 | $font-weight-normal: normal !default; 258 | $font-weight-bold: bold !default; 259 | 260 | $font-weight-base: $font-weight-normal !default; 261 | $line-height-base: 1.5 !default; 262 | 263 | $font-size-h1: 2.5rem !default; 264 | $font-size-h2: 2rem !default; 265 | $font-size-h3: 1.75rem !default; 266 | $font-size-h4: 1.5rem !default; 267 | $font-size-h5: 1.25rem !default; 268 | $font-size-h6: 1rem !default; 269 | 270 | $headings-margin-bottom: ($spacer / 2) !default; 271 | $headings-font-family: inherit !default; 272 | $headings-font-weight: 500 !default; 273 | $headings-line-height: 1.1 !default; 274 | $headings-color: inherit !default; 275 | 276 | $display1-size: 6rem !default; 277 | $display2-size: 5.5rem !default; 278 | $display3-size: 4.5rem !default; 279 | $display4-size: 3.5rem !default; 280 | 281 | $display1-weight: 300 !default; 282 | $display2-weight: 300 !default; 283 | $display3-weight: 300 !default; 284 | $display4-weight: 300 !default; 285 | $display-line-height: $headings-line-height !default; 286 | 287 | $lead-font-size: 1.25rem !default; 288 | $lead-font-weight: 300 !default; 289 | 290 | $small-font-size: 80% !default; 291 | 292 | $text-muted: $gray-light !default; 293 | 294 | $abbr-border-color: $gray-light !default; 295 | 296 | $blockquote-small-color: $gray-light !default; 297 | $blockquote-font-size: ($font-size-base * 1.25) !default; 298 | $blockquote-border-color: $gray-lighter !default; 299 | $blockquote-border-width: .25rem !default; 300 | 301 | $hr-border-color: rgba($black,.1) !default; 302 | $hr-border-width: $border-width !default; 303 | 304 | $mark-padding: .2em !default; 305 | 306 | $dt-font-weight: $font-weight-bold !default; 307 | 308 | $kbd-box-shadow: inset 0 -.1rem 0 rgba($black,.25) !default; 309 | $nested-kbd-font-weight: $font-weight-bold !default; 310 | 311 | $list-inline-padding: 5px !default; 312 | 313 | 314 | // Components 315 | // 316 | // Define common padding and border radius sizes and more. 317 | 318 | $line-height-lg: (4 / 3) !default; 319 | $line-height-sm: 1.5 !default; 320 | 321 | $border-radius: .25rem !default; 322 | $border-radius-lg: .3rem !default; 323 | $border-radius-sm: .2rem !default; 324 | 325 | $component-active-color: $white !default; 326 | $component-active-bg: $brand-primary !default; 327 | 328 | $caret-width: .3em !default; 329 | 330 | $transition-base: all .2s ease-in-out !default; 331 | $transition-fade: opacity .15s linear !default; 332 | $transition-collapse: height .35s ease !default; 333 | 334 | 335 | // Tables 336 | // 337 | // Customizes the `.table` component with basic values, each used across all table variations. 338 | 339 | $table-cell-padding: .75rem !default; 340 | $table-sm-cell-padding: .3rem !default; 341 | 342 | $table-bg: transparent !default; 343 | 344 | $table-inverse-bg: $gray-dark !default; 345 | $table-inverse-color: $body-bg !default; 346 | 347 | $table-bg-accent: rgba($black,.05) !default; 348 | $table-bg-hover: rgba($black,.075) !default; 349 | $table-bg-active: $table-bg-hover !default; 350 | 351 | $table-head-bg: $gray-lighter !default; 352 | $table-head-color: $gray !default; 353 | 354 | $table-border-width: $border-width !default; 355 | $table-border-color: $gray-lighter !default; 356 | 357 | 358 | // Buttons 359 | // 360 | // For each of Bootstrap's buttons, define text, background and border color. 361 | 362 | $btn-padding-x: 1rem !default; 363 | $btn-padding-y: .5rem !default; 364 | $btn-line-height: 1.25 !default; 365 | $btn-font-weight: $font-weight-normal !default; 366 | $btn-box-shadow: inset 0 1px 0 rgba($white,.15), 0 1px 1px rgba($black,.075) !default; 367 | $btn-focus-box-shadow: 0 0 0 2px rgba($brand-primary, .25) !default; 368 | $btn-active-box-shadow: inset 0 3px 5px rgba($black,.125) !default; 369 | 370 | $btn-primary-color: $white !default; 371 | $btn-primary-bg: $brand-primary !default; 372 | $btn-primary-border: $btn-primary-bg !default; 373 | 374 | $btn-secondary-color: $gray-dark !default; 375 | $btn-secondary-bg: $white !default; 376 | $btn-secondary-border: #ccc !default; 377 | 378 | $btn-info-color: $white !default; 379 | $btn-info-bg: $brand-info !default; 380 | $btn-info-border: $btn-info-bg !default; 381 | 382 | $btn-success-color: $white !default; 383 | $btn-success-bg: $brand-success !default; 384 | $btn-success-border: $btn-success-bg !default; 385 | 386 | $btn-warning-color: $white !default; 387 | $btn-warning-bg: $brand-warning !default; 388 | $btn-warning-border: $btn-warning-bg !default; 389 | 390 | $btn-danger-color: $white !default; 391 | $btn-danger-bg: $brand-danger !default; 392 | $btn-danger-border: $btn-danger-bg !default; 393 | 394 | $btn-link-disabled-color: $gray-light !default; 395 | 396 | $btn-padding-x-sm: .5rem !default; 397 | $btn-padding-y-sm: .25rem !default; 398 | 399 | $btn-padding-x-lg: 1.5rem !default; 400 | $btn-padding-y-lg: .75rem !default; 401 | 402 | $btn-block-spacing-y: .5rem !default; 403 | $btn-toolbar-margin: .5rem !default; 404 | 405 | // Allows for customizing button radius independently from global border radius 406 | $btn-border-radius: $border-radius !default; 407 | $btn-border-radius-lg: $border-radius-lg !default; 408 | $btn-border-radius-sm: $border-radius-sm !default; 409 | 410 | $btn-transition: all .2s ease-in-out !default; 411 | 412 | 413 | // Forms 414 | 415 | $input-padding-x: .75rem !default; 416 | $input-padding-y: .5rem !default; 417 | $input-line-height: 1.25 !default; 418 | 419 | $input-bg: $white !default; 420 | $input-bg-disabled: $gray-lighter !default; 421 | 422 | $input-color: $gray !default; 423 | $input-border-color: rgba($black,.15) !default; 424 | $input-btn-border-width: $border-width !default; // For form controls and buttons 425 | $input-box-shadow: inset 0 1px 1px rgba($black,.075) !default; 426 | 427 | $input-border-radius: $border-radius !default; 428 | $input-border-radius-lg: $border-radius-lg !default; 429 | $input-border-radius-sm: $border-radius-sm !default; 430 | 431 | $input-bg-focus: $input-bg !default; 432 | $input-border-focus: lighten($brand-primary, 25%) !default; 433 | $input-box-shadow-focus: $input-box-shadow, rgba($input-border-focus, .6) !default; 434 | $input-color-focus: $input-color !default; 435 | 436 | $input-color-placeholder: $gray-light !default; 437 | 438 | $input-padding-x-sm: .5rem !default; 439 | $input-padding-y-sm: .25rem !default; 440 | 441 | $input-padding-x-lg: 1.5rem !default; 442 | $input-padding-y-lg: .75rem !default; 443 | 444 | $input-height: (($font-size-base * $input-line-height) + ($input-padding-y * 2)) !default; 445 | $input-height-lg: (($font-size-lg * $line-height-lg) + ($input-padding-y-lg * 2)) !default; 446 | $input-height-sm: (($font-size-sm * $line-height-sm) + ($input-padding-y-sm * 2)) !default; 447 | 448 | $input-transition: border-color ease-in-out .15s, box-shadow ease-in-out .15s !default; 449 | 450 | $form-text-margin-top: .25rem !default; 451 | $form-feedback-margin-top: $form-text-margin-top !default; 452 | 453 | $form-check-margin-bottom: .5rem !default; 454 | $form-check-input-gutter: 1.25rem !default; 455 | $form-check-input-margin-y: .25rem !default; 456 | $form-check-input-margin-x: .25rem !default; 457 | 458 | $form-check-inline-margin-x: .75rem !default; 459 | 460 | $form-group-margin-bottom: $spacer-y !default; 461 | 462 | $input-group-addon-bg: $gray-lighter !default; 463 | $input-group-addon-border-color: $input-border-color !default; 464 | 465 | $cursor-disabled: not-allowed !default; 466 | 467 | $custom-control-gutter: 1.5rem !default; 468 | $custom-control-spacer-x: 1rem !default; 469 | $custom-control-spacer-y: .25rem !default; 470 | 471 | $custom-control-indicator-size: 1rem !default; 472 | $custom-control-indicator-margin-y: (($line-height-base * 1rem) - $custom-control-indicator-size) / -2 !default; 473 | $custom-control-indicator-bg: #ddd !default; 474 | $custom-control-indicator-bg-size: 50% 50% !default; 475 | $custom-control-indicator-box-shadow: inset 0 .25rem .25rem rgba($black,.1) !default; 476 | 477 | $custom-control-disabled-cursor: $cursor-disabled !default; 478 | $custom-control-disabled-indicator-bg: $gray-lighter !default; 479 | $custom-control-disabled-description-color: $gray-light !default; 480 | 481 | $custom-control-checked-indicator-color: $white !default; 482 | $custom-control-checked-indicator-bg: $brand-primary !default; 483 | $custom-control-checked-indicator-box-shadow: none !default; 484 | 485 | $custom-control-focus-indicator-box-shadow: 0 0 0 1px $body-bg, 0 0 0 3px $brand-primary !default; 486 | 487 | $custom-control-active-indicator-color: $white !default; 488 | $custom-control-active-indicator-bg: lighten($brand-primary, 35%) !default; 489 | $custom-control-active-indicator-box-shadow: none !default; 490 | 491 | $custom-checkbox-radius: $border-radius !default; 492 | $custom-checkbox-checked-icon: str-replace(url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3E%3Cpath fill='#{$custom-control-checked-indicator-color}' d='M6.564.75l-3.59 3.612-1.538-1.55L0 4.26 2.974 7.25 8 2.193z'/%3E%3C/svg%3E"), "#", "%23") !default; 493 | 494 | $custom-checkbox-indeterminate-bg: $brand-primary !default; 495 | $custom-checkbox-indeterminate-indicator-color: $custom-control-checked-indicator-color !default; 496 | $custom-checkbox-indeterminate-icon: str-replace(url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 4'%3E%3Cpath stroke='#{$custom-checkbox-indeterminate-indicator-color}' d='M0 2h4'/%3E%3C/svg%3E"), "#", "%23") !default; 497 | $custom-checkbox-indeterminate-box-shadow: none !default; 498 | 499 | $custom-radio-radius: 50% !default; 500 | $custom-radio-checked-icon: str-replace(url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3E%3Ccircle r='3' fill='#{$custom-control-checked-indicator-color}'/%3E%3C/svg%3E"), "#", "%23") !default; 501 | 502 | $custom-select-padding-x: .75rem !default; 503 | $custom-select-padding-y: .375rem !default; 504 | $custom-select-indicator-padding: 1rem !default; // Extra padding to account for the presence of the background-image based indicator 505 | $custom-select-line-height: $input-line-height !default; 506 | $custom-select-color: $input-color !default; 507 | $custom-select-disabled-color: $gray-light !default; 508 | $custom-select-bg: $white !default; 509 | $custom-select-disabled-bg: $gray-lighter !default; 510 | $custom-select-bg-size: 8px 10px !default; // In pixels because image dimensions 511 | $custom-select-indicator-color: #333 !default; 512 | $custom-select-indicator: str-replace(url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 4 5'%3E%3Cpath fill='#{$custom-select-indicator-color}' d='M2 0L0 2h4zm0 5L0 3h4z'/%3E%3C/svg%3E"), "#", "%23") !default; 513 | $custom-select-border-width: $input-btn-border-width !default; 514 | $custom-select-border-color: $input-border-color !default; 515 | $custom-select-border-radius: $border-radius !default; 516 | 517 | $custom-select-focus-border-color: lighten($brand-primary, 25%) !default; 518 | $custom-select-focus-box-shadow: inset 0 1px 2px rgba($black, .075), 0 0 5px rgba($custom-select-focus-border-color, .5) !default; 519 | 520 | $custom-select-sm-padding-y: .2rem !default; 521 | $custom-select-sm-font-size: 75% !default; 522 | 523 | $custom-file-height: 2.5rem !default; 524 | $custom-file-width: 14rem !default; 525 | $custom-file-focus-box-shadow: 0 0 0 .075rem $white, 0 0 0 .2rem $brand-primary !default; 526 | 527 | $custom-file-padding-x: .5rem !default; 528 | $custom-file-padding-y: 1rem !default; 529 | $custom-file-line-height: 1.5 !default; 530 | $custom-file-color: $gray !default; 531 | $custom-file-bg: $white !default; 532 | $custom-file-border-width: $border-width !default; 533 | $custom-file-border-color: $input-border-color !default; 534 | $custom-file-border-radius: $border-radius !default; 535 | $custom-file-box-shadow: inset 0 .2rem .4rem rgba($black,.05) !default; 536 | $custom-file-button-color: $custom-file-color !default; 537 | $custom-file-button-bg: $gray-lighter !default; 538 | /*$custom-file-text: ( 539 | placeholder: ( 540 | en: "Choose file..." 541 | ), 542 | button-label: ( 543 | en: "Browse" 544 | ) 545 | ) !default;*/ 546 | 547 | 548 | // Form validation icons 549 | $form-icon-success-color: $brand-success !default; 550 | $form-icon-success: str-replace(url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3E%3Cpath fill='#{$form-icon-success-color}' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3E%3C/svg%3E"), "#", "%23") !default; 551 | 552 | $form-icon-warning-color: $brand-warning !default; 553 | $form-icon-warning: str-replace(url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3E%3Cpath fill='#{$form-icon-warning-color}' d='M4.4 5.324h-.8v-2.46h.8zm0 1.42h-.8V5.89h.8zM3.76.63L.04 7.075c-.115.2.016.425.26.426h7.397c.242 0 .372-.226.258-.426C6.726 4.924 5.47 2.79 4.253.63c-.113-.174-.39-.174-.494 0z'/%3E%3C/svg%3E"), "#", "%23") !default; 554 | 555 | $form-icon-danger-color: $brand-danger !default; 556 | $form-icon-danger: str-replace(url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='#{$form-icon-danger-color}' viewBox='-2 -2 7 7'%3E%3Cpath stroke='%23d9534f' d='M0 0l3 3m0-3L0 3'/%3E%3Ccircle r='.5'/%3E%3Ccircle cx='3' r='.5'/%3E%3Ccircle cy='3' r='.5'/%3E%3Ccircle cx='3' cy='3' r='.5'/%3E%3C/svg%3E"), "#", "%23") !default; 557 | 558 | 559 | // Dropdowns 560 | // 561 | // Dropdown menu container and contents. 562 | 563 | $dropdown-min-width: 10rem !default; 564 | $dropdown-padding-y: .5rem !default; 565 | $dropdown-margin-top: .125rem !default; 566 | $dropdown-bg: $white !default; 567 | $dropdown-border-color: rgba($black,.15) !default; 568 | $dropdown-border-width: $border-width !default; 569 | $dropdown-divider-bg: $gray-lighter !default; 570 | $dropdown-box-shadow: 0 .5rem 1rem rgba($black,.175) !default; 571 | 572 | $dropdown-link-color: $gray-dark !default; 573 | $dropdown-link-hover-color: darken($gray-dark, 5%) !default; 574 | $dropdown-link-hover-bg: $gray-lightest !default; 575 | 576 | $dropdown-link-active-color: $component-active-color !default; 577 | $dropdown-link-active-bg: $component-active-bg !default; 578 | 579 | $dropdown-link-disabled-color: $gray-light !default; 580 | 581 | $dropdown-item-padding-x: 1.5rem !default; 582 | 583 | $dropdown-header-color: $gray-light !default; 584 | 585 | 586 | // Z-index master list 587 | // 588 | // Warning: Avoid customizing these values. They're used for a bird's eye view 589 | // of components dependent on the z-axis and are designed to all work together. 590 | 591 | $zindex-dropdown-backdrop: 990 !default; 592 | $zindex-navbar: 1000 !default; 593 | $zindex-dropdown: 1000 !default; 594 | $zindex-fixed: 1030 !default; 595 | $zindex-sticky: 1030 !default; 596 | $zindex-modal-backdrop: 1040 !default; 597 | $zindex-modal: 1050 !default; 598 | $zindex-popover: 1060 !default; 599 | $zindex-tooltip: 1070 !default; 600 | 601 | 602 | // Navbar 603 | 604 | $navbar-border-radius: $border-radius !default; 605 | $navbar-padding-x: $spacer !default; 606 | $navbar-padding-y: ($spacer / 2) !default; 607 | 608 | $navbar-brand-padding-y: .25rem !default; 609 | 610 | $navbar-toggler-padding-x: .75rem !default; 611 | $navbar-toggler-padding-y: .25rem !default; 612 | $navbar-toggler-font-size: $font-size-lg !default; 613 | $navbar-toggler-border-radius: $btn-border-radius !default; 614 | 615 | $navbar-inverse-color: rgba($white,.5) !default; 616 | $navbar-inverse-hover-color: rgba($white,.75) !default; 617 | $navbar-inverse-active-color: rgba($white,1) !default; 618 | $navbar-inverse-disabled-color: rgba($white,.25) !default; 619 | $navbar-inverse-toggler-bg: str-replace(url("data:image/svg+xml;charset=utf8,%3Csvg viewBox='0 0 32 32' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath stroke='#{$navbar-inverse-color}' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 8h24M4 16h24M4 24h24'/%3E%3C/svg%3E"), "#", "%23") !default; 620 | $navbar-inverse-toggler-border: rgba($white,.1) !default; 621 | 622 | $navbar-light-color: rgba($black,.5) !default; 623 | $navbar-light-hover-color: rgba($black,.7) !default; 624 | $navbar-light-active-color: rgba($black,.9) !default; 625 | $navbar-light-disabled-color: rgba($black,.3) !default; 626 | $navbar-light-toggler-bg: str-replace(url("data:image/svg+xml;charset=utf8,%3Csvg viewBox='0 0 32 32' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath stroke='#{$navbar-light-color}' stroke-width='2' stroke-linecap='round' stroke-miterlimit='10' d='M4 8h24M4 16h24M4 24h24'/%3E%3C/svg%3E"), "#", "%23") !default; 627 | $navbar-light-toggler-border: rgba($black,.1) !default; 628 | 629 | // Navs 630 | 631 | $nav-item-margin: .2rem !default; 632 | $nav-item-inline-spacer: 1rem !default; 633 | $nav-link-padding: .5em 1em !default; 634 | $nav-link-hover-bg: $gray-lighter !default; 635 | $nav-disabled-link-color: $gray-light !default; 636 | 637 | $nav-tabs-border-color: #ddd !default; 638 | $nav-tabs-border-width: $border-width !default; 639 | $nav-tabs-border-radius: $border-radius !default; 640 | $nav-tabs-link-hover-border-color: $gray-lighter !default; 641 | $nav-tabs-active-link-hover-color: $gray !default; 642 | $nav-tabs-active-link-hover-bg: $body-bg !default; 643 | $nav-tabs-active-link-hover-border-color: #ddd !default; 644 | $nav-tabs-justified-link-border-color: #ddd !default; 645 | $nav-tabs-justified-active-link-border-color: $body-bg !default; 646 | 647 | $nav-pills-border-radius: $border-radius !default; 648 | $nav-pills-active-link-color: $component-active-color !default; 649 | $nav-pills-active-link-bg: $component-active-bg !default; 650 | 651 | 652 | // Pagination 653 | 654 | $pagination-padding-x: .75rem !default; 655 | $pagination-padding-y: .5rem !default; 656 | $pagination-padding-x-sm: .5rem !default; 657 | $pagination-padding-y-sm: .25rem !default; 658 | $pagination-padding-x-lg: 1.5rem !default; 659 | $pagination-padding-y-lg: .75rem !default; 660 | $pagination-line-height: 1.25 !default; 661 | 662 | $pagination-color: $link-color !default; 663 | $pagination-bg: $white !default; 664 | $pagination-border-width: $border-width !default; 665 | $pagination-border-color: #ddd !default; 666 | 667 | $pagination-hover-color: $link-hover-color !default; 668 | $pagination-hover-bg: $gray-lighter !default; 669 | $pagination-hover-border: #ddd !default; 670 | 671 | $pagination-active-color: $white !default; 672 | $pagination-active-bg: $brand-primary !default; 673 | $pagination-active-border: $brand-primary !default; 674 | 675 | $pagination-disabled-color: $gray-light !default; 676 | $pagination-disabled-bg: $white !default; 677 | $pagination-disabled-border: #ddd !default; 678 | 679 | 680 | // Jumbotron 681 | 682 | $jumbotron-padding: 2rem !default; 683 | $jumbotron-bg: $gray-lighter !default; 684 | 685 | 686 | // Form states and alerts 687 | // 688 | // Define colors for form feedback states and, by default, alerts. 689 | 690 | $state-success-text: #3c763d !default; 691 | $state-success-bg: #dff0d8 !default; 692 | $state-success-border: darken($state-success-bg, 5%) !default; 693 | 694 | $state-info-text: #31708f !default; 695 | $state-info-bg: #d9edf7 !default; 696 | $state-info-border: darken($state-info-bg, 7%) !default; 697 | 698 | $state-warning-text: #8a6d3b !default; 699 | $state-warning-bg: #fcf8e3 !default; 700 | $mark-bg: $state-warning-bg !default; 701 | $state-warning-border: darken($state-warning-bg, 5%) !default; 702 | 703 | $state-danger-text: #a94442 !default; 704 | $state-danger-bg: #f2dede !default; 705 | $state-danger-border: darken($state-danger-bg, 5%) !default; 706 | 707 | 708 | // Cards 709 | 710 | $card-spacer-x: 1.25rem !default; 711 | $card-spacer-y: .75rem !default; 712 | $card-border-width: 1px !default; 713 | $card-border-radius: $border-radius !default; 714 | $card-border-color: rgba($black,.125) !default; 715 | $card-border-radius-inner: calc(#{$card-border-radius} - #{$card-border-width}) !default; 716 | $card-cap-bg: $gray-lightest !default; 717 | $card-bg: $white !default; 718 | 719 | $card-link-hover-color: $white !default; 720 | 721 | $card-img-overlay-padding: 1.25rem !default; 722 | 723 | $card-deck-margin: ($grid-gutter-width-base / 2) !default; 724 | 725 | $card-columns-count: 3 !default; 726 | $card-columns-gap: 1.25rem !default; 727 | $card-columns-margin: $card-spacer-y !default; 728 | 729 | 730 | // Tooltips 731 | 732 | $tooltip-max-width: 200px !default; 733 | $tooltip-color: $white !default; 734 | $tooltip-bg: $black !default; 735 | $tooltip-opacity: .9 !default; 736 | $tooltip-padding-y: 3px !default; 737 | $tooltip-padding-x: 8px !default; 738 | $tooltip-margin: 3px !default; 739 | 740 | $tooltip-arrow-width: 5px !default; 741 | $tooltip-arrow-color: $tooltip-bg !default; 742 | 743 | 744 | // Popovers 745 | 746 | $popover-inner-padding: 1px !default; 747 | $popover-bg: $white !default; 748 | $popover-max-width: 276px !default; 749 | $popover-border-width: $border-width !default; 750 | $popover-border-color: rgba($black,.2) !default; 751 | $popover-box-shadow: 0 5px 10px rgba($black,.2) !default; 752 | 753 | $popover-title-bg: darken($popover-bg, 3%) !default; 754 | $popover-title-padding-x: 14px !default; 755 | $popover-title-padding-y: 8px !default; 756 | 757 | $popover-content-padding-x: 14px !default; 758 | $popover-content-padding-y: 9px !default; 759 | 760 | $popover-arrow-width: 10px !default; 761 | $popover-arrow-color: $popover-bg !default; 762 | 763 | $popover-arrow-outer-width: ($popover-arrow-width + 1px) !default; 764 | $popover-arrow-outer-color: fade-in($popover-border-color, .05) !default; 765 | 766 | 767 | // Badges 768 | 769 | $badge-default-bg: $gray-light !default; 770 | $badge-primary-bg: $brand-primary !default; 771 | $badge-success-bg: $brand-success !default; 772 | $badge-info-bg: $brand-info !default; 773 | $badge-warning-bg: $brand-warning !default; 774 | $badge-danger-bg: $brand-danger !default; 775 | 776 | $badge-color: $white !default; 777 | $badge-link-hover-color: $white !default; 778 | $badge-font-size: 75% !default; 779 | $badge-font-weight: $font-weight-bold !default; 780 | $badge-padding-x: .4em !default; 781 | $badge-padding-y: .25em !default; 782 | 783 | $badge-pill-padding-x: .6em !default; 784 | // Use a higher than normal value to ensure completely rounded edges when 785 | // customizing padding or font-size on labels. 786 | $badge-pill-border-radius: 10rem !default; 787 | 788 | 789 | // Modals 790 | 791 | // Padding applied to the modal body 792 | $modal-inner-padding: 15px !default; 793 | 794 | $modal-dialog-margin: 10px !default; 795 | $modal-dialog-sm-up-margin-y: 30px !default; 796 | 797 | $modal-title-line-height: $line-height-base !default; 798 | 799 | $modal-content-bg: $white !default; 800 | $modal-content-border-color: rgba($black,.2) !default; 801 | $modal-content-border-width: $border-width !default; 802 | $modal-content-xs-box-shadow: 0 3px 9px rgba($black,.5) !default; 803 | $modal-content-sm-up-box-shadow: 0 5px 15px rgba($black,.5) !default; 804 | 805 | $modal-backdrop-bg: $black !default; 806 | $modal-backdrop-opacity: .5 !default; 807 | $modal-header-border-color: $gray-lighter !default; 808 | $modal-footer-border-color: $modal-header-border-color !default; 809 | $modal-header-border-width: $modal-content-border-width !default; 810 | $modal-footer-border-width: $modal-header-border-width !default; 811 | $modal-header-padding: 15px !default; 812 | 813 | $modal-lg: 800px !default; 814 | $modal-md: 500px !default; 815 | $modal-sm: 300px !default; 816 | 817 | $modal-transition: transform .3s ease-out !default; 818 | 819 | 820 | // Alerts 821 | // 822 | // Define alert colors, border radius, and padding. 823 | 824 | $alert-padding-x: 1.25rem !default; 825 | $alert-padding-y: .75rem !default; 826 | $alert-margin-bottom: $spacer-y !default; 827 | $alert-border-radius: $border-radius !default; 828 | $alert-link-font-weight: $font-weight-bold !default; 829 | $alert-border-width: $border-width !default; 830 | 831 | $alert-success-bg: $state-success-bg !default; 832 | $alert-success-text: $state-success-text !default; 833 | $alert-success-border: $state-success-border !default; 834 | 835 | $alert-info-bg: $state-info-bg !default; 836 | $alert-info-text: $state-info-text !default; 837 | $alert-info-border: $state-info-border !default; 838 | 839 | $alert-warning-bg: $state-warning-bg !default; 840 | $alert-warning-text: $state-warning-text !default; 841 | $alert-warning-border: $state-warning-border !default; 842 | 843 | $alert-danger-bg: $state-danger-bg !default; 844 | $alert-danger-text: $state-danger-text !default; 845 | $alert-danger-border: $state-danger-border !default; 846 | 847 | 848 | // Progress bars 849 | 850 | $progress-height: 1rem !default; 851 | $progress-font-size: .75rem !default; 852 | $progress-bg: $gray-lighter !default; 853 | $progress-border-radius: $border-radius !default; 854 | $progress-box-shadow: inset 0 .1rem .1rem rgba($black,.1) !default; 855 | $progress-bar-color: $white !default; 856 | $progress-bar-bg: $brand-primary !default; 857 | $progress-bar-animation-timing: 1s linear infinite !default; 858 | 859 | // List group 860 | 861 | $list-group-color: $body-color !default; 862 | $list-group-bg: $white !default; 863 | $list-group-border-color: rgba($black,.125) !default; 864 | $list-group-border-width: $border-width !default; 865 | $list-group-border-radius: $border-radius !default; 866 | 867 | $list-group-item-padding-x: 1.25rem !default; 868 | $list-group-item-padding-y: .75rem !default; 869 | 870 | $list-group-hover-bg: $gray-lightest !default; 871 | $list-group-active-color: $component-active-color !default; 872 | $list-group-active-bg: $component-active-bg !default; 873 | $list-group-active-border: $list-group-active-bg !default; 874 | $list-group-active-text-color: lighten($list-group-active-bg, 50%) !default; 875 | 876 | $list-group-disabled-color: $gray-light !default; 877 | $list-group-disabled-bg: $list-group-bg !default; 878 | $list-group-disabled-text-color: $list-group-disabled-color !default; 879 | 880 | $list-group-link-color: $gray !default; 881 | $list-group-link-heading-color: $gray-dark !default; 882 | $list-group-link-hover-color: $list-group-link-color !default; 883 | 884 | $list-group-link-active-color: $list-group-color !default; 885 | $list-group-link-active-bg: $gray-lighter !default; 886 | 887 | 888 | // Image thumbnails 889 | 890 | $thumbnail-padding: .25rem !default; 891 | $thumbnail-bg: $body-bg !default; 892 | $thumbnail-border-width: $border-width !default; 893 | $thumbnail-border-color: #ddd !default; 894 | $thumbnail-border-radius: $border-radius !default; 895 | $thumbnail-box-shadow: 0 1px 2px rgba($black,.075) !default; 896 | $thumbnail-transition: all .2s ease-in-out !default; 897 | 898 | 899 | // Figures 900 | 901 | $figure-caption-font-size: 90% !default; 902 | $figure-caption-color: $gray-light !default; 903 | 904 | 905 | // Breadcrumbs 906 | 907 | $breadcrumb-padding-y: .75rem !default; 908 | $breadcrumb-padding-x: 1rem !default; 909 | $breadcrumb-item-padding: .5rem !default; 910 | 911 | $breadcrumb-bg: $gray-lighter !default; 912 | $breadcrumb-divider-color: $gray-light !default; 913 | $breadcrumb-active-color: $gray-light !default; 914 | $breadcrumb-divider: "/" !default; 915 | 916 | 917 | // Carousel 918 | 919 | $carousel-control-color: $white !default; 920 | $carousel-control-width: 15% !default; 921 | $carousel-control-opacity: .5 !default; 922 | 923 | $carousel-indicator-width: 30px !default; 924 | $carousel-indicator-height: 3px !default; 925 | $carousel-indicator-spacer: 3px !default; 926 | $carousel-indicator-active-bg: $white !default; 927 | 928 | $carousel-caption-width: 70% !default; 929 | $carousel-caption-color: $white !default; 930 | 931 | $carousel-control-icon-width: 20px !default; 932 | 933 | $carousel-control-prev-icon-bg: str-replace(url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='#{$carousel-control-color}' viewBox='0 0 8 8'%3E%3Cpath d='M4 0l-4 4 4 4 1.5-1.5-2.5-2.5 2.5-2.5-1.5-1.5z'/%3E%3C/svg%3E"), "#", "%23") !default; 934 | $carousel-control-next-icon-bg: str-replace(url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='#{$carousel-control-color}' viewBox='0 0 8 8'%3E%3Cpath d='M1.5 0l-1.5 1.5 2.5 2.5-2.5 2.5 1.5 1.5 4-4-4-4z'/%3E%3C/svg%3E"), "#", "%23") !default; 935 | 936 | $carousel-transition: transform .6s ease-in-out !default; 937 | 938 | 939 | // Close 940 | 941 | $close-font-size: $font-size-base * 1.5 !default; 942 | $close-font-weight: $font-weight-bold !default; 943 | $close-color: $black !default; 944 | $close-text-shadow: 0 1px 0 $white !default; 945 | 946 | 947 | // Code 948 | 949 | $code-font-size: 90% !default; 950 | $code-padding-x: .4rem !default; 951 | $code-padding-y: .2rem !default; 952 | $code-color: #bd4147 !default; 953 | $code-bg: $gray-lightest !default; 954 | 955 | $kbd-color: $white !default; 956 | $kbd-bg: $gray-dark !default; 957 | 958 | $pre-bg: $gray-lightest !default; 959 | $pre-color: $gray-dark !default; 960 | $pre-border-color: #ccc !default; 961 | $pre-scrollable-max-height: 340px !default; 962 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # VueJS 2 Example Project (and Tutorial) 3 | 4 | A scalable Single Page Application (SPA) example. This example uses Vue-cli, VueRouter, Vuex, VueResource and more. Clone the repo, do `npm install`, and use right away or read through this tutorial below to get an idea of how to build the project from scratch and setup Sublime Text. 5 | 6 | ## Table of Contents 7 | 1. [Todo](#todo) 8 | 2. [Install Node](#install-node) 9 | 3. [Install Vue-CLI](#install-vue-cli) 10 | 4. [Add Dependencies](#add-dependencies) 11 | 5. [Configure JQuery and Lodash and Tether](#configure-jquery-and-lodash-and-tether) 12 | 6. [Global Utilities](#global-utilities) 13 | 7. [Configure Sublime Text 3](#configure-sublime-text-3) 14 | 8. [Configure ESLint](#configure-eslint) 15 | 9. [Setup Main and Routes](#setup-main-and-routes) 16 | 10. [Setup Authentication (OAuth2), User Profile, and Vuex](#setup-authentication-user-profile-and-vuex) 17 | 11. [Proxy Api Calls in Webpack Dev Server](#proxy-api-calls-in-webpack-dev-server) 18 | 12. [Components](#components) 19 | 13. [Twitter Bootstrap 4 Configuration](#twitter-bootstrap-4-configuration) 20 | 14. [Fonts and Font-Awesome](#fonts-and-font-awesome) 21 | 15. [Images and Other Assets](#images-and-other-assets) 22 | 16. [App.scss](#app-scss) 23 | 17. [Unit Testing and End-to-End Testing](#unit-testing-and-end-to-end-testing) 24 | 18. [Run the Dev Server](#run-the-dev-server) 25 | 19. [Vue Dev Tools](#vue-dev-tools) 26 | 20. [Create & Publish a Component/Library for Other Developers to Use](#create-and-publish-a-library-for-other-developers-to-use) 27 | 21. [Quick Learning Webpack Resources](#quick-learning-webpack-resources) 28 | 29 | ## Todo 30 | 31 | - Currently, remote calls are made to an online example OAuth2 demo server [here](http://brentertainment.com/oauth2/) by Brent Shaffer. We can remove this and instead setup up a Node.js Express OAuth2. 32 | - Add a section in this tutorial about working in a production environment. 33 | - File splitting (Webpack's CommonChunksPlugin and etc.) and improving page load times. 34 | 35 | ## Install Node 36 | 37 | #### Install Node and NPM (Using PPA to get latest version) 38 | 39 | Get the setup script: 40 | 41 | ```shell 42 | $ cd ~ 43 | $ curl -sL https://deb.nodesource.com/setup_6.x -o nodesource_setup.sh 44 | ``` 45 | 46 | Inspect that you have the script, then run with `sudo`: 47 | 48 | ```shell 49 | $ vim nodesource_setup.sh 50 | $ sudo bash nodesource_setup.sh 51 | ``` 52 | 53 | Now install Nodejs: 54 | 55 | ```shell 56 | $ sudo apt-get install nodejs 57 | ``` 58 | 59 | The nodejs package contains the nodejs binary as well as npm, so you don't need to install npm separately. However, in order for some npm packages to work (such as those that require compiling code from source), you will need to install the build-essential package: 60 | 61 | ```shell 62 | $ sudo apt-get install build-essential 63 | ``` 64 | 65 | ## Install Vue-CLI 66 | 67 | Change directory to the directory where you want this example project to reside: 68 | 69 | ```shell 70 | # an example folder will be created here on the next step... 71 | $ cd ~ 72 | ``` 73 | 74 | Install Vue-cli with webpack: 75 | 76 | ```shell 77 | $ sudo npm install -g vue-cli 78 | $ vue init webpack example-vue-project 79 | ``` 80 | 81 | (Note: If you've already installed the cli before and when you init a new project you get the message: `A newer version of vue-cli is available`, then ctrl+c at the prompt and then: `sudo npm install vue-cli -g` to update (re-install) vue-cli to the latest version.) 82 | 83 | Now you'll get some output like this: 84 | 85 | ``` 86 | ? Project name: example-vue-project 87 | ? Project description: A Vue.js project 88 | ? Author: Your Name 89 | ? Vue build: Runtime-only # saves you 6kb 90 | ? Install vue-router? Y 91 | ? Use ESLint to lint your code? Y 92 | ? Pick an ESLint preset: none # we'll use a vue specific preset based on Standard 93 | ? Setup unit tests with Karma + Mocha? Y 94 | ? Setup e2e tests with Nightwatch? Y 95 | 96 | vue-cli Generated "example-vue-project" 97 | ``` 98 | 99 | Install dependencies in `package.json`: 100 | 101 | ```shell 102 | $ cd example-vue-project 103 | $ npm install # do this first before you add more dependencies (to avoid peer warns) 104 | ``` 105 | 106 | ## Add Dependencies 107 | 108 | (Note: We are installing most dependencies into the devDependencies section of package.json using `--save-dev`. The production server will not need those dependencies. Most of these dependencies are used to build a set of files into your `dist` folder for your production server to use.) 109 | 110 | Install Vuex and Vue Resource (Vue Router was installed from vue-cli earlier) 111 | 112 | ```shell 113 | $ npm install vuex vue-resource --save 114 | ``` 115 | 116 | Install jQuery, Tether (required by Boostrap), Bootstrap, Font-Awesome, Roboto and Lodash 117 | 118 | ```shell 119 | $ npm install jquery tether bootstrap@next font-awesome roboto-fontface lodash --save-dev 120 | ``` 121 | 122 | Install Vue Multiselect (a vendor component used in an example) 123 | 124 | ```shell 125 | $ npm install vue-multiselect@next --save-dev 126 | ``` 127 | 128 | Install Vue ESLint plugin 129 | 130 | ```shell 131 | $ npm install eslint-config-vue eslint-plugin-vue --save-dev 132 | ``` 133 | 134 | Install babel-polyfill (for example, transpiling es6 promises, so that tests will work in testing browsers...see section on "Unit Testing and End-to-End Testing" further down). 135 | 136 | ```shell 137 | npm install babel-polyfill --save-dev 138 | ``` 139 | 140 | Install sass builders: 141 | 142 | ```shell 143 | $ npm install sass-loader node-sass --save-dev 144 | ``` 145 | 146 | Install stylus (optional): 147 | 148 | ```shell 149 | $ npm install stylus -g # install stylus globally for Sublime 150 | $ stylus -V # this confirms that stylus has been added to your path, if not, you need to do so for it to work correctly with Sublime 151 | $ npm install stylus stylus-loader --save-dev # also install locally 152 | ``` 153 | 154 | See Sublime Text 3 section further down for installing the Stylus package for it. 155 | 156 | *(This concludes all extra dependencies, however feel free to check the `package.json` in the Github repo)* 157 | 158 | ## Configure JQuery and Lodash and Tether 159 | 160 | #### Option #1: Use ProvidePlugin 161 | 162 | Add the [ProvidePlugin](https://webpack.github.io/docs/list-of-plugins.html#provideplugin) to the plugins array in both `build/webpack.dev.conf.js` and `build/webpack.prod.conf.js` so that jQuery and Lodash become globally available to all your modules (and also Tether for Bootstrap): 163 | 164 | #### build/webpack.dev.conf.js, build/webpack.prod.conf.js 165 | 166 | ```js 167 | plugins: [ 168 | 169 | // ... 170 | 171 | new webpack.ProvidePlugin({ 172 | $: 'jquery', 173 | jquery: 'jquery', 174 | 'window.jQuery': 'jquery', 175 | jQuery: 'jquery', 176 | '_': 'lodash', 177 | 'Tether': 'tether', 178 | utils: 'utils' 179 | }) 180 | ] 181 | ``` 182 | 183 | *Note: The `utils` property is for a set of utility functions we want global to all modules. See the section [Global Utilities](#global-utilities) for more information on how this is set up.* 184 | 185 | #### Option #2: Use Expose Loader module for webpack 186 | 187 | Alternatively you can add the [Expose Loader](https://www.npmjs.com/package/expose-loader) package: 188 | 189 | ```shell 190 | npm install expose-loader --save-dev 191 | ``` 192 | 193 | Use in your entry point `main.js` like this: 194 | 195 | ```shell 196 | import 'expose?$!expose?jQuery!jquery' 197 | 198 | // ... 199 | ``` 200 | ## Global Utilities 201 | 202 | Using the `ProvidePlugin` in the previous section, we were able to include jQuery and Lodash in all modules that used it. But these were from node_modules. What if we want to do this with one of our own modules from our project (so we don't have to directly require it each time we need it). In the previous section you can see we added `utils` to the ProvidePlugin. Now let's actually create a module (in the Node form) in our `src/` directory for keeping these utilities we want globally: 203 | 204 | #### src/utils.js 205 | 206 | ```js 207 | 208 | module.exports = { 209 | 210 | /** 211 | * Get the error from a response. 212 | * 213 | * @param {Response} response The Vue-resource Response that we will try to get errors from. 214 | */ 215 | getError: function (response) { 216 | return response.body['error_description'] 217 | ? response.body.error_description 218 | : response.statusText 219 | } 220 | } 221 | 222 | ``` 223 | 224 | In the section [Configure ESLint](#configure-eslint) you will notice we have added **utils** to the globals so that the linter will not complain when we use it. 225 | 226 | For the `utils` to work in the ProvidePlugin you could just require it directly, but it gives a warning and build fails (because it's an expression). So let's work around this by adding it to the set of aliases in `webpack.base.conf.js`: 227 | 228 | #### build/webpack.base.conf.js 229 | 230 | ```js 231 | 232 | module.exports = { 233 | 234 | // ... 235 | 236 | resolve: { 237 | extensions: ['.js', '.vue', '.json'], 238 | alias: { 239 | '@': resolve('src'), 240 | 'utils': resolve('src/utils') 241 | } 242 | }, 243 | 244 | // ... 245 | 246 | } 247 | 248 | ``` 249 | 250 | So we use the `utils` alias in the plugin: 251 | 252 | #### build/webpack.dev.conf.js, build/webpack.prod.conf.js 253 | 254 | ```js 255 | plugins: [ 256 | 257 | // ... 258 | 259 | new webpack.ProvidePlugin({ 260 | $: 'jquery', 261 | jquery: 'jquery', 262 | 'window.jQuery': 'jquery', 263 | jQuery: 'jquery', 264 | '_': 'lodash', 265 | 'Tether': 'tether', 266 | utils: 'utils' 267 | }) 268 | ] 269 | ``` 270 | 271 | Take a look in the `Login.vue` component to see how we use this utility to display an error message (when login credentials are invalid). Note: A more scalable way to handle responses/errors in your app would be to standardize them in your backend API. For example, when an error happens, the backend API can still return a 200, but include an error property with detail in the returned response JSON. 272 | 273 | ## Configure Sublime Text 3 274 | 275 | #### Install Package Control 276 | https://packagecontrol.io/installation 277 | 278 | #### Install Babel syntax definitions for ES6 JavaScript 279 | 280 | * Go to `Preferences > Package Control > Install Package` or press `ctrl+shift+p` (Win, Linux) or `cmd+shift+p` (OS X) and search for "Package Control: Install Package". 281 | * Search for the package "Babel" and install it. 282 | * Open any .js file in Sublime. Then go to `View > Syntax > Open all with current extension as... > Babel > Javascript (Babel)`. 283 | 284 | #### Install a theme that works well with Babel. 285 | For example, here's how you can install the **Oceanic Next** theme: 286 | 287 | * Try the Oceanic Next theme: `Open Package Control -> Install Package` and search for Oceanic Next color theme. 288 | * Go to `Preferences > Oceanic Next Color theme > Oceanic next`. 289 | 290 | #### Setup soft tabs and 2 space indention 291 | 292 | * Open any .js file. Go to `Preferences > Settings - More > Syntax Specific - User`. 293 | * It should open a file like `JavaScript (Babel).sublime-settings` 294 | * Add these parameters to the file: 295 | 296 | ``` 297 | { 298 | "extensions": 299 | [ 300 | "js" 301 | ], 302 | "tab_size": 2, 303 | "translate_tabs_to_spaces": true 304 | } 305 | 306 | ``` 307 | * Open any .vue file and repeat this process. 308 | 309 | #### Install Stylus package for Sublime 310 | 311 | * Open `Package Control: Install Package` and search for `Stylus` and install it (should be billymoon/Stylus package). 312 | * Restart Sublime. 313 | 314 | #### Install Sublime-linter and ESLinter 315 | 316 | * Open `Package Control: Install Package` and search for `SublimeLinter` and install it. 317 | * Search for `SublimeLinter-contrib-eslint` and install it as well. 318 | * Restart Sublime. 319 | 320 | *Note: In the next section you'll configure eslint. If you install eslint into the same directory you are modifying Sublime files from (and same machine), then Sublimelinter will have no problem using it. Or you can also install eslint (and all the other eslint-* packages) globally on the same machine as Sublime. But if you are using a server or a virtual machine (Vagrant/Virtualbox) configuration, then you need to tell sublimelinter where eslint is. You can change the path with `Sublime Text -> Prefences -> Package Settings -> SublimeLinter -> Settings-User`.* 321 | 322 | ## Configure ESLint 323 | 324 | Let's add some more things to eslint from the default given. You'll need to restart Sublime each time you makes changes to this file. One thing to point out is the `env` and `globals` properties. These are necessary so eslint doesn't complain about use of these globals in our JS files (and so we don't have to add something like `/* globals localStorage */` to the top of those files to suppress the errors). See other sections in this tutorial, [Configure JQuery](#configure-jquery) and [Global Helpers](#global-helpers) for information about working in a global context in Webpack. 325 | 326 | Make sure you installed the additional eslint dependencies: 327 | 328 | ```shell 329 | $ npm install eslint-config-vue eslint-plugin-vue --save-dev 330 | ``` 331 | 332 | Now open up your eslintrc.js file and make the following changes: 333 | 334 | #### eslintrc.js 335 | 336 | ```js 337 | module.exports = { 338 | root: true, 339 | parser: 'babel-eslint', 340 | parserOptions: { 341 | sourceType: 'module' 342 | }, 343 | // required for eslint-config-vue 344 | extends: 'vue', 345 | // required to lint *.vue files 346 | plugins: [ 347 | 'html' 348 | ], 349 | env: { 350 | browser: true 351 | }, 352 | globals: { 353 | '$': true, 354 | '_': true, 355 | 'utils': true 356 | }, 357 | // add your custom rules here 358 | 'rules': { 359 | // allow debugger during development 360 | 'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0 361 | } 362 | } 363 | ``` 364 | 365 | #### .eslintignore 366 | 367 | You can tell ESLint to [ignore specific files and directories](http://eslint.org/docs/user-guide/configuring.html#ignoring-files-and-directories) by using an `.eslintignore` file in your project’s root directory: 368 | 369 | **.eslintignore** 370 | 371 | build/*.js 372 | config/*.js 373 | 374 | The ignore patterns behave according to the `.gitignore` specification. 375 | (Don't forget to restart your editor, ie. SublimeText3) 376 | 377 | ## Setup Main and Routes 378 | 379 | #### src/main.js 380 | 381 | ```js 382 | /* Twitter Bootstrap JS (this could also be handled in an app.js file) */ 383 | require('bootstrap') 384 | 385 | /* Vue */ 386 | import Vue from 'vue' 387 | import router from './router' 388 | import store from './store' 389 | import VueResource from 'vue-resource' 390 | 391 | Vue.use(VueResource) 392 | Vue.config.productionTip = false 393 | 394 | /* App sass */ 395 | import './assets/style/app.scss' 396 | 397 | /* App component */ 398 | import App from './components/App.vue' 399 | 400 | /* Auth plugin */ 401 | import Auth from './auth' 402 | Vue.use(Auth) 403 | 404 | /* eslint-disable no-new */ 405 | new Vue({ 406 | el: '#app', 407 | // Attach the Vue instance to the window, 408 | // so it's available globally. 409 | created: function () { 410 | window.Vue = this 411 | }, 412 | router, 413 | store, 414 | render: h => h(App) 415 | }) 416 | 417 | ``` 418 | #### src/router/index.js 419 | 420 | ```js 421 | import Vue from 'vue' 422 | import Router from 'vue-router' 423 | 424 | Vue.use(Router) 425 | 426 | const router = new Router({ 427 | mode: 'history', 428 | routes: [ 429 | // Each of these routes are loaded asynchronously, when a user first navigates to each corresponding endpoint. 430 | // The route will load once into memory, the first time it's called, and no more on future calls. 431 | // This behavior can be observed on the network tab of your browser dev tools. 432 | { 433 | path: '/login', 434 | name: 'login', 435 | component: function (resolve) { 436 | require(['@/components/login/Login.vue'], resolve) 437 | } 438 | }, 439 | { 440 | path: '/signup', 441 | name: 'signup', 442 | component: function (resolve) { 443 | require(['@/components/signup/Signup.vue'], resolve) 444 | } 445 | }, 446 | { 447 | path: '/', 448 | name: 'dashboard', 449 | component: function (resolve) { 450 | require(['@/components/dashboard/Dashboard.vue'], resolve) 451 | }, 452 | beforeEnter: guardRoute 453 | } 454 | ] 455 | }) 456 | 457 | function guardRoute (to, from, next) { 458 | // work-around to get to the Vuex store (as of Vue 2.0) 459 | const auth = router.app.$options.store.state.auth 460 | 461 | if (!auth.isLoggedIn) { 462 | next({ 463 | path: '/login', 464 | query: { redirect: to.fullPath } 465 | }) 466 | } else { 467 | next() 468 | } 469 | } 470 | 471 | export default router 472 | 473 | ``` 474 | 475 | ## Setup Authentication, User Profile, and Vuex 476 | 477 | Create a folder called `store` in the `src` directory: 478 | 479 | ```shell 480 | $ mkdir store 481 | ``` 482 | 483 | Now let's create the following files that will comprise our central Vuex storage. 484 | 485 | ### Vuex State 486 | 487 | Let's setup the state of our central data storage. We'll want some state to be available accross browser tabs (and when the app is closed/reopened) so let's sync this state with LocalStorage. When the app bootstraps, we want to first check in the browser's localStorage and retrieve all of our previously stored data. We'll also have other state we can use for to make component-to-component communication easier (for situations where you don't have a simple parent-child communication, but more complex sibling-to-sibling or other component relationships). Let's just add a property for storing the search text and button press on the navbar for demonstration purposes. 488 | 489 | #### src/store/state.js 490 | 491 | ```js 492 | // Set the key we'll use in local storage. 493 | // Go to Chrome dev tools, application tab, click "Local Storage" and "http://localhost:8080" 494 | // and you'll see this key set below (if logged in): 495 | export const STORAGE_KEY = 'example-vue-project' 496 | 497 | let initialState = {} 498 | 499 | // Local storage sync state 500 | if (localStorage.getItem(STORAGE_KEY)) { 501 | initialState = JSON.parse(localStorage.getItem(STORAGE_KEY)) 502 | } else { 503 | initialState = { 504 | auth: { 505 | isLoggedIn: false, 506 | accessToken: null, 507 | refreshToken: null 508 | }, 509 | user: { 510 | name: null 511 | } 512 | } 513 | } 514 | 515 | // Other state (not synced in local storage) 516 | initialState.appnav = { 517 | searchText: '', 518 | searchTimestamp: null 519 | } 520 | 521 | export const state = initialState 522 | 523 | ``` 524 | 525 | ### Vuex Mutations, Getters, and Actions 526 | 527 | Now create a file to hold all the methods that will change the state in our Vuex store: 528 | 529 | #### src/store/mutations.js 530 | 531 | ```js 532 | export const UPDATE_AUTH = (state, auth) => { 533 | state.auth = auth 534 | } 535 | 536 | export const UPDATE_USER = (state, user) => { 537 | state.user = user 538 | } 539 | 540 | export const APPNAV_SEARCH = (state, searchData) => { 541 | state.appnav = searchData 542 | } 543 | 544 | /** 545 | * Clear each property, one by one, so reactivity still works. 546 | * 547 | * (ie. clear out state.auth.isLoggedIn so Navbar component automatically reacts to logged out state, 548 | * and the Navbar menu adjusts accordingly) 549 | * 550 | * TODO: use a common import of default state to reset these values with. 551 | */ 552 | export const CLEAR_ALL_DATA = (state) => { 553 | // Auth 554 | state.auth.isLoggedIn = false 555 | state.auth.accessToken = null 556 | state.auth.refreshToken = null 557 | 558 | // User 559 | state.user.name = '' 560 | } 561 | 562 | 563 | ``` 564 | 565 | And some getters (although you can accesss the Vuex state directly as we'll see shortly): 566 | 567 | #### src/store/getters.js 568 | 569 | ```js 570 | export const user = state => state.user 571 | ``` 572 | 573 | We'll also go ahead and add an actions file (but leave it empty for this project since we don't need it): 574 | 575 | #### src/store/actions.js 576 | 577 | ```js 578 | // Here is where you can put async operations. 579 | // See the Vuex official docs for more information. 580 | 581 | // ... 582 | 583 | ``` 584 | 585 | ### Vuex Plugins 586 | Plugins offer a nice approach to hook into mutations and do things like logging or syncing with another store such as `localStorage` or `websockets`: 587 | 588 | #### src/store/plugins.js 589 | 590 | ```js 591 | import { STORAGE_KEY } from './state' 592 | 593 | const localStoragePlugin = store => { 594 | store.subscribe((mutation, state) => { 595 | const syncedData = { auth: state.auth, user: state.user } 596 | 597 | localStorage.setItem(STORAGE_KEY, JSON.stringify(syncedData)) 598 | 599 | if (mutation.type === 'CLEAR_ALL_DATA') { 600 | localStorage.removeItem(STORAGE_KEY) 601 | } 602 | }) 603 | } 604 | 605 | // TODO: setup env 606 | // export default process.env.NODE_ENV !== 'production' ? [localStoragePlugin] : [localStoragePlugin] 607 | export default [localStoragePlugin] 608 | 609 | ``` 610 | 611 | ### Vuex index.js 612 | 613 | And bring it all together in the index.js file: 614 | 615 | #### src/store/index.js 616 | 617 | ```js 618 | import Vue from 'vue' 619 | import Vuex from 'vuex' 620 | import { state } from './state' 621 | import * as getters from './getters' 622 | import * as actions from './actions' 623 | import * as mutations from './mutations' 624 | import plugins from './plugins' 625 | 626 | Vue.use(Vuex) 627 | 628 | const store = new Vuex.Store({ 629 | state, 630 | getters, 631 | actions, 632 | mutations, 633 | plugins 634 | }) 635 | 636 | export default store 637 | ``` 638 | ### Auth Script 639 | 640 | Now let's add our auth script. Here we handle getting **OAuth2** access_tokens and automatically refreshing them. 641 | 642 | #### src/auth.js 643 | 644 | ```js 645 | import Vue from 'vue' 646 | import router from './router' 647 | import store from './store' 648 | 649 | /** 650 | * @var{string} LOGIN_URL The endpoint for logging in. This endpoint should be proxied by Webpack dev server 651 | * and maybe nginx in production (cleaner calls and avoids CORS issues). 652 | */ 653 | const LOGIN_URL = '/auth' 654 | 655 | /** 656 | * @var{string} REFRESH_TOKEN_URL The endpoint for refreshing an access_token. This endpoint should be proxied 657 | * by Webpack dev server and maybe nginx in production (cleaner calls and avoids CORS issues). 658 | */ 659 | const REFRESH_TOKEN_URL = '/auth' 660 | 661 | /** 662 | * TODO: This is here to demonstrate what an OAuth server will want. Ultimately you don't want to 663 | * expose a client_secret here. You want your real project backend to take a username/password 664 | * request and add the client secret on the server-side and forward that request 665 | * onto an OAuth server. Your backend acts as a middle-man in the process, which is better, for 666 | * example in situations like DDoS attacks. 667 | * 668 | * @var{Object} AUTH_BASIC_HEADERS The options to pass into a Vue-resource http call. Includes 669 | * the headers used for login and token refresh and emulateJSON flag since we are hitting an 670 | * OAuth server directly that can't handle application/json. 671 | */ 672 | const AUTH_BASIC_HEADERS = { 673 | headers: { 674 | 'Authorization': 'Basic ZGVtb2FwcDpkZW1vcGFzcw==' // Base64(client_id:client_secret) "demoapp:demopass" 675 | }, 676 | emulateJSON: true 677 | } 678 | 679 | /** 680 | * Auth Plugin 681 | * 682 | * (see https://vuejs.org/v2/guide/plugins.html for more info on Vue.js plugins) 683 | * 684 | * Handles login and token authentication using OAuth2. 685 | */ 686 | export default { 687 | 688 | /** 689 | * Install the Auth class. 690 | * 691 | * Creates a Vue-resource http interceptor to handle automatically adding auth headers 692 | * and refreshing tokens. Then attaches this object to the global Vue (as Vue.auth). 693 | * 694 | * @param {Object} Vue The global Vue. 695 | * @param {Object} options Any options we want to have in our plugin. 696 | * @return {void} 697 | */ 698 | install (Vue, options) { 699 | Vue.http.interceptors.push((request, next) => { 700 | const token = store.state.auth.accessToken 701 | const hasAuthHeader = request.headers.has('Authorization') 702 | 703 | if (token && !hasAuthHeader) { 704 | this.setAuthHeader(request) 705 | } 706 | 707 | next((response) => { 708 | if (this._isInvalidToken(response)) { 709 | return this._refreshToken(request) 710 | } 711 | }) 712 | }) 713 | 714 | Vue.prototype.$auth = Vue.auth = this 715 | }, 716 | 717 | /** 718 | * Login 719 | * 720 | * @param {Object.} creds The username and password for logging in. 721 | * @param {string|null} redirect The name of the Route to redirect to. 722 | * @return {Promise} 723 | */ 724 | login (creds, redirect) { 725 | const params = { 'grant_type': 'password', 'username': creds.username, 'password': creds.password } 726 | 727 | return Vue.http.post(LOGIN_URL, params, AUTH_BASIC_HEADERS) 728 | .then((response) => { 729 | this._storeToken(response) 730 | 731 | if (redirect) { 732 | router.push({ name: redirect }) 733 | } 734 | 735 | return response 736 | }) 737 | .catch((errorResponse) => { 738 | return errorResponse 739 | }) 740 | }, 741 | 742 | /** 743 | * Logout 744 | * 745 | * Clear all data in our Vuex store (which resets logged-in status) and redirect back 746 | * to login form. 747 | * 748 | * @return {void} 749 | */ 750 | logout () { 751 | store.commit('CLEAR_ALL_DATA') 752 | router.push({ name: 'login' }) 753 | }, 754 | 755 | /** 756 | * Set the Authorization header on a Vue-resource Request. 757 | * 758 | * @param {Request} request The Vue-Resource Request instance to set the header on. 759 | * @return {void} 760 | */ 761 | setAuthHeader (request) { 762 | request.headers.set('Authorization', 'Bearer ' + store.state.auth.accessToken) 763 | // The demo Oauth2 server we are using requires this param, but normally you only set the header. 764 | /* eslint-disable camelcase */ 765 | request.params.access_token = store.state.auth.accessToken 766 | }, 767 | 768 | /** 769 | * Retry the original request. 770 | * 771 | * Let's retry the user's original target request that had recieved a invalid token response 772 | * (which we fixed with a token refresh). 773 | * 774 | * @param {Request} request The Vue-resource Request instance to use to repeat an http call. 775 | * @return {Promise} 776 | */ 777 | _retry (request) { 778 | this.setAuthHeader(request) 779 | 780 | return Vue.http(request) 781 | .then((response) => { 782 | return response 783 | }) 784 | .catch((response) => { 785 | return response 786 | }) 787 | }, 788 | 789 | /** 790 | * Refresh the access token 791 | * 792 | * Make an ajax call to the OAuth2 server to refresh the access token (using our refresh token). 793 | * 794 | * @private 795 | * @param {Request} request Vue-resource Request instance, the original request that we'll retry. 796 | * @return {Promise} 797 | */ 798 | _refreshToken (request) { 799 | const params = { 'grant_type': 'refresh_token', 'refresh_token': store.state.auth.refreshToken } 800 | 801 | return Vue.http.post(REFRESH_TOKEN_URL, params, AUTH_BASIC_HEADERS) 802 | .then((result) => { 803 | this._storeToken(result) 804 | return this._retry(request) 805 | }) 806 | .catch((errorResponse) => { 807 | if (this._isInvalidToken(errorResponse)) { 808 | this.logout() 809 | } 810 | return errorResponse 811 | }) 812 | }, 813 | 814 | /** 815 | * Store tokens 816 | * 817 | * Update the Vuex store with the access/refresh tokens received from the response from 818 | * the Oauth2 server. 819 | * 820 | * @private 821 | * @param {Response} response Vue-resource Response instance from an OAuth2 server. 822 | * that contains our tokens. 823 | * @return {void} 824 | */ 825 | _storeToken (response) { 826 | const auth = store.state.auth 827 | const user = store.state.user 828 | 829 | auth.isLoggedIn = true 830 | auth.accessToken = response.body.access_token 831 | auth.refreshToken = response.body.refresh_token 832 | // TODO: get user's name from response from Oauth server. 833 | user.name = 'John Smith' 834 | 835 | store.commit('UPDATE_AUTH', auth) 836 | store.commit('UPDATE_USER', user) 837 | }, 838 | 839 | /** 840 | * Check if the Vue-resource Response is an invalid token response. 841 | * 842 | * @private 843 | * @param {Response} response The Vue-resource Response instance received from an http call. 844 | * @return {boolean} 845 | */ 846 | _isInvalidToken (response) { 847 | const status = response.status 848 | const error = response.data.error 849 | 850 | return (status === 401 && (error === 'invalid_token' || error === 'expired_token')) 851 | } 852 | } 853 | 854 | ``` 855 | 856 | Checkout out `Login.vue` component to see how we use `Auth`. Also take a look at `Dashboard.vue` component, you can see the Vue-resource http interceptors let us not worry about including authorization headers in our AJAX calls. The interceptors also take care of refreshing tokens behind the scenes. See the comments marked "TODO" for some caveats with this demo and your own project. I hope to update this demo using a Node Express OAuth2 server for better demonstration of Auth flow. 857 | 858 | ## Proxy Api Calls in Webpack Dev Server 859 | 860 | When using Webpack for Hot Reloading, we'll need to tell the webpack dev server that `/api` calls need to be reverse proxied to another server (ie. running on node express, nginx, or some embedded server in your backend IDE). For production you would just use nginx to do the proxying. The big advantage is we don't have to worry about CORS and also we don't expose the true API endpoints to the client. 861 | 862 | Notice in `build/dev-server.js` this line: 863 | 864 | ```js 865 | // proxy api requests 866 | Object.keys(proxyTable).forEach(function (context) { 867 | var options = proxyTable[context] 868 | if (typeof options === 'string') { 869 | options = { target: options } 870 | } 871 | app.use(proxyMiddleware(context, options)) 872 | }) 873 | ``` 874 | 875 | In this setup we are using: https://github.com/chimurai/http-proxy-middleware (you can see examples there). So let's add options to our config to make this work: 876 | 877 | In `config/index.js`, update the *proxyTable* object to look like this: 878 | 879 | ```js 880 | dev: { 881 | 882 | // ... 883 | 884 | proxyTable: { 885 | '/auth': { 886 | // TODO: Update to use node express oauth2 server for better example. 887 | target: 'http://brentertainment.com/oauth2/lockdin/token', // <-- demo oauth2 server, https://github.com/bshaffer/oauth2-demo-php 888 | changeOrigin: true, 889 | ws: true, 890 | pathRewrite: { 891 | '^/auth': '' 892 | }, 893 | router: { 894 | } 895 | }, 896 | '/api': { 897 | target: 'http://brentertainment.com/oauth2', // api server 898 | changeOrigin: true, // needed for virtual hosted sites 899 | ws: true, // proxy websockets 900 | pathRewrite: { 901 | '^/api': '/lockdin' // rewrite path localhost:8080/api to http://brentertainment.com/oauth2/lockdin 902 | }, 903 | router: { 904 | // when request.headers.host == 'dev.localhost:3000', 905 | // override target 'http://www.example.org' to 'http://localhost:8000' 906 | // 'dev.localhost:3000': 'http://localhost:8000' 907 | } 908 | } 909 | }, 910 | 911 | // ... 912 | } 913 | ``` 914 | 915 | ## Components 916 | 917 | Delete the `App.vue` file located in /src folder: 918 | 919 | ```shell 920 | $ rm App.vue 921 | ``` 922 | 923 | In the `/src/components` folder create the following folders and .Vue files (just copy these directly from this repo): 924 | 925 | ``` 926 | /src 927 | /components 928 | - App.vue 929 | - AppFooter.vue 930 | - AppNav.vue 931 | - Hello.vue 932 | /common 933 | - Countries.vue 934 | - Spinner.vue 935 | - countries.data.js 936 | /dashboard 937 | - Dashboard.vue 938 | - AddressModal.vue 939 | /login 940 | - Login.vue 941 | /signup 942 | - Signup.vue 943 | /users 944 | 945 | ``` 946 | 947 | Here we use a folder for each "page" in our SPA. This allows us to represent "pages" with more than a single .Vue file. We can ad other supporting .Vue components, .js files, or data files. There's also a `common` folder to put any components we feel don't necessarily belong to a page parent. If over time you feel there are too many folders, you can further group/consolidate pages into folders ("page group folders"). 948 | 949 | 950 | ## Twitter Bootstrap 4 Configuration 951 | 952 | - Install Bootstrap 4 and Tether.js, see section: [Add Dependencies](#add-dependencies). 953 | - Add Tether to providePlugin, see section: [Configure JQuery and Lodash and Tether](#configure-jquery-and-lodash-and-tether) 954 | - Require in main.js: see section: [Setup Main and Routes](#setup-main-and-routes) 955 | - Add a folder `style` (if you haven't already) to your `/assets` directory and create the following file: 956 | 957 | #### src/assets/style/_variables.scss 958 | 959 | ```scss 960 | // copy and paste here everything from the node_modules/bootstrap/scss/_variables.scss 961 | // Then make adjustments to variables for your specific app. 962 | 963 | ``` 964 | - Import into your app.scss, see section: [App scss](#app-scss). 965 | 966 | ## Fonts and Font-Awesome 967 | 968 | Install packages (if you haven't already from earlier section ): 969 | 970 | ``` 971 | npm install font-awesome roboto-fontface --save-dev 972 | ``` 973 | 974 | Then add `_fonts.scss` stylesheet. We'll setup your fonts and also `font-awesome` here: 975 | 976 | #### src/assets/style/_fonts.scss 977 | 978 | ```scss 979 | /* Font Awesome */ 980 | $fa-font-path: '../../../node_modules/font-awesome/fonts'; 981 | @import '../../../node_modules/font-awesome/scss/font-awesome'; 982 | 983 | /* Roboto */ 984 | $roboto-font-path: '../../../node_modules/roboto-fontface/fonts'; 985 | @import '../../../node_modules/roboto-fontface/css/roboto/sass/roboto-fontface'; 986 | 987 | ``` 988 | 989 | ## Images and Other Assets 990 | 991 | Create an images folder at `src/assets/images` then cut an paste the Vue `logo.png` file that resides in the assets folder by default. The Navbar component uses a relative link to this image, which Webpack will resolve for us automatically. 992 | 993 | You can read more about static assets here: https://vuejs-templates.github.io/webpack/static.html 994 | 995 | ## App scss 996 | 997 | Bring everything to together into an `app.scss` file that we import in our main entry: 998 | 999 | #### src/assets/style/app.scss 1000 | 1001 | ``` 1002 | @import 'fonts'; 1003 | @import 'variables'; 1004 | @import '../../../node_modules/bootstrap/scss/bootstrap'; 1005 | @import '../../../node_modules/vue-multiselect/dist/vue-multiselect.min.css'; 1006 | 1007 | ``` 1008 | 1009 | Of course if this file gets too big, you can break it up into different supporting files: `_forms.scss`, `_blah-blah.scss`, etc. 1010 | 1011 | ## Unit Testing and End-to-End Testing 1012 | 1013 | Make sure you installed `babel-polyfill` earlier in this tutorial or es6 promises won't work in PhantomJS. If you didn't, you can install it with: 1014 | 1015 | ```shell 1016 | npm install babel-polyfill --save-dev 1017 | ``` 1018 | 1019 | Then update your `test/unit/karma.conf.js` file to include the polyfill: 1020 | 1021 | 1022 | #### test/unit/karma.conf.js 1023 | 1024 | ```js 1025 | 1026 | //... 1027 | 1028 | files: [ 1029 | '../../node_modules/babel-polyfill/dist/polyfill.js', 1030 | './index.js' 1031 | ], 1032 | ``` 1033 | 1034 | A unit test is included from the Webpack template already. It's a simple example that tests the content outputted from the Hello vue component: 1035 | 1036 | #### test/unit/specs/Hello.spec.js 1037 | 1038 | ```js 1039 | import Vue from 'vue' 1040 | import Hello from 'src/components/Hello' 1041 | 1042 | describe('Hello.vue', () => { 1043 | it('should render correct contents', () => { 1044 | const vm = new Vue({ 1045 | el: document.createElement('div'), 1046 | render: (h) => h(Hello) 1047 | }) 1048 | expect(vm.$el.querySelector('.hello h1').textContent) 1049 | .to.equal('Welcome to Your Vue.js App') 1050 | }) 1051 | }) 1052 | 1053 | ``` 1054 | 1055 | #### End-to-End Testing with Nightwatch.js and Selenium server 1056 | 1057 | I find End-to-End testing and Integration testing even more beneficial. Vue-cli has put together a nice setup that includes Nightwatch.js (which uses Selenium and a Chrome driver) for e2e testing right out of the box. Let's remove the existing test located at `test/e2e/specs/test.js` since it will no longer work with the changes we have made. Let's add a new test that tests that our login form works and that we can reach the dashboard: 1058 | 1059 | #### test/e2e/specs/loginTest.js 1060 | 1061 | ```js 1062 | // For authoring Nightwatch tests, see 1063 | // http://nightwatchjs.org/guide#usage 1064 | 1065 | /** 1066 | * Test that user can login and see dashboard. 1067 | */ 1068 | module.exports = { 1069 | 'default e2e tests': function (browser) { 1070 | // automatically uses dev Server port from /config.index.js 1071 | // default: http://localhost:8080 1072 | // see nightwatch.conf.js 1073 | const devServer = browser.globals.devServerURL 1074 | 1075 | browser 1076 | .url(devServer) 1077 | .waitForElementVisible('#app', 5000) 1078 | 1079 | // Assert that user can see login. 1080 | .assert.elementPresent('.login') 1081 | .setValue('.js-login__username', 'demouser') 1082 | .setValue('.js-login__password', 'testpass') 1083 | .click('.js-login__submit') 1084 | .pause(1000) 1085 | 1086 | // Assert that user can see dashboard. 1087 | .assert.containsText('.ev-dashboard__heading h1', 'This is the dashboard') 1088 | .pause(2000) 1089 | .end() 1090 | } 1091 | } 1092 | 1093 | ``` 1094 | 1095 | *Note: You may wish to add another `assert` that asserts the dashboard is unreachable when a user is logged out.* 1096 | 1097 | #### Running the Tests 1098 | 1099 | Now let's run both the unit test and the e2e test. Make sure you are in your project directory, then: 1100 | 1101 | ```shell 1102 | npm run test 1103 | ``` 1104 | 1105 | You should see some output initially showing the results of each unit test ran: 1106 | 1107 | > Hello.vue 1108 | ✓ should render correct contents 1109 | ... 1110 | PhantomJS 2.1.1 (Linux 0.0.0): Executed 1 of 1 SUCCESS (0.018 secs / 0.004 secs) 1111 | TOTAL: 1 SUCCESS 1112 | 1113 | Then the Selenium server will fire up Chrome browser and run the e2e tests to see if those pass: 1114 | 1115 | > ✔ Element <#app> was visible after 65 milliseconds. 1116 | ✔ Testing if element <.ev-login> is present. 1117 | ✔ Testing if element <.ev-dashboard__heading> contains text: "This is the dashboard". 1118 | ... 1119 | OK. 3 assertions passed. (18.522s) 1120 | 1121 | You can of course run unit tests and e2e tests seperately with: `npm run unit` and `npm run e2e`. 1122 | 1123 | ## Run the Dev Server 1124 | 1125 | Run the dev server: 1126 | 1127 | ```shell 1128 | $ cd ~/example-vue-project 1129 | $ npm run dev 1130 | ``` 1131 | 1132 | Open your browser and visit http://localhost:8080 . You should see something like this: 1133 | 1134 | 1135 | 1136 | 1137 | ## Vue Dev Tools 1138 | 1139 | Visit the Chrome Web Store to get the [Vue Dev Tools extension](https://chrome.google.com/webstore/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd) for helping debug Vue.js applications. 1140 | 1141 | Once installed, Open Chrome dev tools and go to the "Vue" tab. 1142 | 1143 | #### Vuex Tab 1144 | If you click on the "Vuex" tab, you can see all data from the store in the right pane. Click the `export button` to copy the data to the clipboard. Click the `import button` and paste the clipboard data there. 1145 | 1146 | For example, you can alter the *accessToken* to something invalid (to simulate an expired *oauth access_token* without waiting on actual expiration) in the pasted data. Then click the `import button` again and the Vuex store will live update. Now you can confirm that the automatic refreshToken interceptor works. 1147 | 1148 | ## Create and Publish a Library for Other Developers to Use 1149 | 1150 | So now you want to go further and develop a component that others can `npm install` and import into their own project? 1151 | Here you go: https://github.com/prograhammer/vue-library-template 1152 | 1153 | ## Quick Learning Webpack Resources 1154 | 1155 | - SurviveJs: https://survivejs.com/webpack/introduction/ 1156 | - Official Webpack Tutorial: https://webpack.js.org/guides/get-started/ 1157 | --------------------------------------------------------------------------------