From 2139f3d9dab12ec4197a25663577c0b9d16fa42a Mon Sep 17 00:00:00 2001 From: Bram Wiepjes <bramw@protonmail.com> Date: Mon, 15 Jul 2019 20:34:29 +0200 Subject: [PATCH] created base layout, reuseable context and logging off --- .../assets/scss/abstracts/_variables.scss | 7 + web-frontend/assets/scss/base/_base.scss | 4 + web-frontend/assets/scss/base/_helpers.scss | 4 + .../assets/scss/components/_context.scss | 2 +- web-frontend/components/.gitkeep | 0 web-frontend/components/Context.vue | 158 ++++++++++ web-frontend/config/nuxt.config.base.js | 6 +- web-frontend/directives/clickOutside.js | 19 ++ web-frontend/directives/moveToBody.js | 18 ++ web-frontend/layouts/app.vue | 278 ++++++++++++++++++ web-frontend/layouts/default.vue | 5 - web-frontend/pages/app/index.vue | 2 +- web-frontend/plugins/directives.js | 7 + web-frontend/store/auth.js | 21 +- web-frontend/store/sidebar.js | 24 ++ web-frontend/utils/dom.js | 9 + 16 files changed, 554 insertions(+), 10 deletions(-) delete mode 100644 web-frontend/components/.gitkeep create mode 100644 web-frontend/components/Context.vue create mode 100644 web-frontend/directives/clickOutside.js create mode 100644 web-frontend/directives/moveToBody.js create mode 100644 web-frontend/layouts/app.vue delete mode 100644 web-frontend/layouts/default.vue create mode 100644 web-frontend/plugins/directives.js create mode 100644 web-frontend/store/sidebar.js create mode 100644 web-frontend/utils/dom.js diff --git a/web-frontend/assets/scss/abstracts/_variables.scss b/web-frontend/assets/scss/abstracts/_variables.scss index e1812d75d..01ca4e9ac 100644 --- a/web-frontend/assets/scss/abstracts/_variables.scss +++ b/web-frontend/assets/scss/abstracts/_variables.scss @@ -64,3 +64,10 @@ $z-index-layout-col-3: 1; $z-index-layout-col-3-1: 5; $z-index-layout-col-3-2: 4; $z-index-modal: 6; + +// The z-index of the context must always be the highest because they can open in a +// modal. +$z-index-context: 7; + +// normalize overrides +$base-font-family: $text-font-stack; diff --git a/web-frontend/assets/scss/base/_base.scss b/web-frontend/assets/scss/base/_base.scss index 52b9444b5..1501f6d02 100644 --- a/web-frontend/assets/scss/base/_base.scss +++ b/web-frontend/assets/scss/base/_base.scss @@ -11,6 +11,10 @@ body { background-color: $color-neutral-100; } +a { + cursor: pointer; +} + *, *::before, *::after { diff --git a/web-frontend/assets/scss/base/_helpers.scss b/web-frontend/assets/scss/base/_helpers.scss index 8e74fb1e2..878ef3703 100644 --- a/web-frontend/assets/scss/base/_helpers.scss +++ b/web-frontend/assets/scss/base/_helpers.scss @@ -8,6 +8,10 @@ display: none; } +.visibility-hidden { + visibility: hidden; +} + .align-right { text-align: right; } diff --git a/web-frontend/assets/scss/components/_context.scss b/web-frontend/assets/scss/components/_context.scss index b3114459b..60d93208d 100644 --- a/web-frontend/assets/scss/components/_context.scss +++ b/web-frontend/assets/scss/components/_context.scss @@ -1,6 +1,6 @@ .context { position: absolute; - z-index: 1; + z-index: $z-index-context; white-space: nowrap; background-color: $white; border-radius: 6px; diff --git a/web-frontend/components/.gitkeep b/web-frontend/components/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/web-frontend/components/Context.vue b/web-frontend/components/Context.vue new file mode 100644 index 000000000..f96ec1fdb --- /dev/null +++ b/web-frontend/components/Context.vue @@ -0,0 +1,158 @@ +<template> + <div + v-move-to-body + v-click-outside="hide" + class="context" + :class="{ 'visibility-hidden': !open }" + > + <slot></slot> + </div> +</template> + +<script> +import { isElement } from '@/utils/dom' + +export default { + name: 'Context', + data() { + return { + open: false, + opener: null, + children: [] + } + }, + /** + * Because we don't want the parent context to close when a user clicks 'outside' that + * element and in the child element we need to register the child with their parent to + * prevent this. + */ + beforeMount() { + let $parent = this.$parent + while ($parent !== undefined) { + if ($parent.registerContextChild) { + $parent.registerContextChild(this.$el) + break + } + $parent = $parent.$parent + } + }, + methods: { + /** + * Toggles the open state of the context menu. + * + * @param target The original element element that changed the state of the + * context, this will be used to calculate the correct position. + * @param vertical Bottom positions the context under the target. + * Top positions the context above the target. + * @param horizontal Left aligns the context with the left side of the target. + * Right aligns the context with the right side of the target. + * @param offset The distance between the target element and the context. + * @param value True if context must be shown, false if not and undefine + * will invert the current state. + */ + toggle( + target, + vertical = 'bottom', + horizontal = 'left', + offset = 10, + value + ) { + if (value === undefined) { + value = !this.open + } + + if (value) { + const css = this.calculatePosition(target, vertical, horizontal, offset) + + // Set the calculated positions of the context. + for (const key in css) { + const value = css[key] !== null ? Math.ceil(css[key]) + 'px' : 'auto' + this.$el.style[key] = value + } + } + + // If we store the element who opened the context menu we can exclude the element + // when clicked outside of this element. + this.opener = value ? target : null + this.open = value + }, + hide(event) { + // Checks if the click is inside one of our children. In that code the context + // must stay open. + const isChild = this.children.some(element => + isElement(element, event.target) + ) + + // Checks if the context is already opened, if the click was not on the opener + // because he can trigger the toggle method and if the click was not in one of + // our child contexts. + if (this.open && !isElement(this.opener, event.target) && !isChild) { + this.open = false + } + }, + /** + * Calculates the absolute position of the context based on the original clicked + * element. + */ + calculatePosition(target, vertical, horizontal, offset) { + const targetRect = target.getBoundingClientRect() + const contextRect = this.$el.getBoundingClientRect() + const positions = { top: null, right: null, bottom: null, left: null } + + // Calculate if top, bottom, left and right positions are possible. + const canTop = targetRect.top - contextRect.height - offset > 0 + const canBottom = + window.innerHeight - targetRect.bottom - contextRect.height - offset > 0 + const canRight = targetRect.right - contextRect.width > 0 + const canLeft = + window.innerWidth - targetRect.left - contextRect.width > 0 + + // If bottom, top, left or right doesn't fit, but their opposite does we switch to + // that. + if (vertical === 'bottom' && !canBottom && canTop) { + vertical = 'top' + } + + if (vertical === 'top' && !canTop) { + vertical = 'bottom' + } + + if (horizontal === 'left' && !canLeft && canRight) { + horizontal = 'right' + } + + if (horizontal === 'right' && !canRight) { + horizontal = 'left' + } + + // Calculate the correct positions for horizontal and vertical values. + if (horizontal === 'left') { + positions.left = targetRect.left + } + + if (horizontal === 'right') { + positions.right = window.innerWidth - targetRect.right + } + + if (vertical === 'bottom') { + positions.top = targetRect.bottom + offset + } + + if (vertical === 'top') { + positions.bottom = window.innerHeight - targetRect.top + offset + } + + return positions + }, + /** + * A child context can register itself with the parent to prevent closing of the + * parent when clicked inside the child. + * + * @param element HTMLElement + */ + registerContextChild(element) { + this.children.push(element) + } + } +} +</script> diff --git a/web-frontend/config/nuxt.config.base.js b/web-frontend/config/nuxt.config.base.js index 5bdb09e02..f090cdcef 100644 --- a/web-frontend/config/nuxt.config.base.js +++ b/web-frontend/config/nuxt.config.base.js @@ -25,7 +25,11 @@ export default { /* ** Plugins to load before mounting the App */ - plugins: [{ src: '@/plugins/auth.js' }, { src: '@/plugins/vuelidate.js' }], + plugins: [ + { src: '@/plugins/auth.js' }, + { src: '@/plugins/directives.js' }, + { src: '@/plugins/vuelidate.js' } + ], /* ** Nuxt.js modules diff --git a/web-frontend/directives/clickOutside.js b/web-frontend/directives/clickOutside.js new file mode 100644 index 000000000..6d1c7bf3b --- /dev/null +++ b/web-frontend/directives/clickOutside.js @@ -0,0 +1,19 @@ +import { isElement } from '@/utils/dom' + +/** + * This directive calls a custom method if the user clicks outside of the + * element. + */ +export default { + bind: (el, binding, vnode) => { + el.clickOutsideEvent = event => { + if (!isElement(el, event.target)) { + vnode.context[binding.expression](event) + } + } + document.body.addEventListener('click', el.clickOutsideEvent) + }, + unbind: el => { + document.body.removeEventListener('click', el.clickOutsideEvent) + } +} diff --git a/web-frontend/directives/moveToBody.js b/web-frontend/directives/moveToBody.js new file mode 100644 index 000000000..c51404d09 --- /dev/null +++ b/web-frontend/directives/moveToBody.js @@ -0,0 +1,18 @@ +/** + * This directive moves the whole element to the document body so that it can be + * positioned over another element. + */ +export default { + inserted: el => { + const body = document.body + + // The element is added as first child in the body so that child contexts + // are being shown on top of their parent. + body.insertBefore(el, body.firstChild) + }, + unbind: el => { + if (el.parentNode) { + el.parentNode.removeChild(el) + } + } +} diff --git a/web-frontend/layouts/app.vue b/web-frontend/layouts/app.vue new file mode 100644 index 000000000..a2c921b46 --- /dev/null +++ b/web-frontend/layouts/app.vue @@ -0,0 +1,278 @@ +<template> + <div :class="{ 'layout-collapsed': isCollapsed }" class="layout"> + <div class="layout-col-1 menu"> + <ul class="menu-items"> + <li class="menu-item"> + <nuxt-link :to="{ name: 'app' }" class="menu-link"> + <i class="fas fa-tachometer-alt"></i> + <span class="menu-link-text">Dashboard</span> + </nuxt-link> + </li> + <li class="menu-item"> + <a href="#" class="menu-link" data-context=".select"> + <i class="fas fa-layer-group"></i> + <span class="menu-link-text">Groups</span> + </a> + <div class="select hidden"> + <div class="select-search"> + <i class="select-search-icon fas fa-search"></i> + <input + type="text" + class="select-search-input" + placeholder="Search views" + /> + </div> + <ul class="select-items"> + <li class="select-item active"> + <a href="#" class="select-item-link">Group name 1</a> + <a href="#" class="select-item-options" data-context=".context"> + <i class="fas fa-ellipsis-v"></i> + </a> + <div class="context hidden"> + <ul class="context-menu"> + <li> + <a href="#"> + <i class="context-menu-icon fas fa-fw fa-pen"></i> + Rename group + </a> + </li> + <li> + <a href="#"> + <i class="context-menu-icon fas fa-fw fa-trash"></i> + Delete group + </a> + </li> + </ul> + </div> + </li> + <li class="select-item"> + <a href="#" class="select-item-link">Group name 2</a> + <a href="#" class="select-item-options"> + <i class="fas fa-ellipsis-v"></i> + </a> + </li> + <li class="select-item"> + <a href="#" class="select-item-link">Group name 3</a> + <a href="#" class="select-item-options"> + <i class="fas fa-ellipsis-v"></i> + </a> + </li> + <li class="select-item"> + <a href="#" class="select-item-link">Group name 4</a> + <a href="#" class="select-item-options"> + <i class="fas fa-ellipsis-v"></i> + </a> + </li> + </ul> + <div class="select-footer"> + <a href="#" class="select-footer-button"> + <i class="fas fa-plus"></i> + Do something + </a> + </div> + </div> + </li> + </ul> + <ul class="menu-items"> + <li class="menu-item layout-uncollapse"> + <a class="menu-link" @click="toggleCollapsed()"> + <i class="menu-item-icon fas fa-angle-double-right"></i> + <span class="menu-link-text">Uncollapse</span> + </a> + </li> + <li class="menu-item"> + <a + class="menu-link menu-user-item" + @click="$refs.userContext.toggle($event.target)" + > + {{ nameAbbreviation }} + <span class="menu-link-text">{{ name }}</span> + </a> + <Context ref="userContext"> + <div class="context-menu-title">{{ name }}</div> + <ul class="context-menu"> + <li> + <a @click="logoff()"> + <i class="context-menu-icon fas fa-fw fa-sign-out-alt"></i> + Logoff + </a> + </li> + </ul> + </Context> + </li> + </ul> + </div> + <div class="layout-col-2 sidebar"> + <div class="sidebar-content-wrapper"> + <nav class="sidebar-content"> + <div class="sidebar-title"> + <img src="@/static/img/logo.svg" alt="" /> + </div> + <div class="sidebar-group-title">Group name 1</div> + <ul class="tree"> + <li class="tree-item"> + <div class="tree-action"> + <a href="#" class="tree-link"> + <i class="tree-type fas fa-database"></i> + Vehicles + </a> + <a href="#" class="tree-options" data-context=".context"> + <i class="fas fa-ellipsis-v"></i> + </a> + <div class="context hidden"> + <div class="context-menu-title">Vehicles</div> + <ul class="context-menu"> + <li> + <a href="#"> + <i class="context-menu-icon fas fa-fw fa-pen"></i> + Rename database + </a> + </li> + <li> + <a href="#"> + <i class="context-menu-icon fas fa-fw fa-trash"></i> + Delete table + </a> + </li> + </ul> + </div> + </div> + </li> + <li class="tree-item"> + <div class="tree-action"> + <a href="#" class="tree-link"> + <i class="tree-type fas fa-angle-right"></i> + Map nummer 1 + </a> + </div> + </li> + <li class="tree-item active"> + <div class="tree-action"> + <a href="#" class="tree-link"> + <i class="tree-type fas fa-database"></i> + Webshop + </a> + <a href="#" class="tree-options"> + <i class="fas fa-ellipsis-v"></i> + </a> + </div> + <ul class="tree-subs"> + <li class="tree-sub active"> + <a href="#" class="tree-sub-link">Customers</a> + <a href="#" class="tree-options"> + <i class="fas fa-ellipsis-v"></i> + </a> + </li> + <li class="tree-sub"> + <a href="#" class="tree-sub-link">Products very long name</a> + <a href="#" class="tree-options"> + <i class="fas fa-ellipsis-v"></i> + </a> + </li> + <li class="tree-sub"> + <a href="#" class="tree-sub-link">Categories</a> + <a href="#" class="tree-options"> + <i class="fas fa-ellipsis-v"></i> + </a> + </li> + </ul> + </li> + <li class="tree-item"> + <div class="tree-action"> + <a href="#" class="tree-link"> + <i class="tree-type fas fa-angle-down"></i> + Map nummer 1 + </a> + </div> + <ul class="tree"> + <li class="tree-item"> + <div class="tree-action"> + <a href="#" class="tree-link"> + <i class="tree-type fas fa-database"></i> + Vehicles + </a> + <a href="#" class="tree-options"> + <i class="fas fa-ellipsis-v"></i> + </a> + </div> + </li> + <li class="tree-item"> + <div class="tree-action"> + <a href="#" class="tree-link"> + <i class="tree-type fas fa-database"></i> + Something + </a> + <a href="#" class="tree-options"> + <i class="fas fa-ellipsis-v"></i> + </a> + </div> + </li> + </ul> + </li> + <li class="tree-item"> + <div class="tree-action"> + <a href="#" class="tree-link"> + <i class="tree-type fas fa-database"></i> + Vehicles with very long name and that is not good. + </a> + <a href="#" class="tree-options"> + <i class="fas fa-ellipsis-v"></i> + </a> + </div> + </li> + <li class="tree-item"> + <div class="tree-action"> + <a href="#" class="tree-link"> + <i class="tree-type fas fa-database"></i> + Something else + </a> + <a href="#" class="tree-options"> + <i class="fas fa-ellipsis-v"></i> + </a> + </div> + </li> + </ul> + </nav> + </div> + <div class="sidebar-footer"> + <a class="sidebar-collapse" @click="toggleCollapsed()"> + <i class="fas fa-angle-double-left"></i> + Collapse sidebar + </a> + </div> + </div> + <div class="layout-col-3"> + <nuxt /> + </div> + </div> +</template> + +<script> +import { mapActions, mapGetters } from 'vuex' + +import Context from '@/components/Context' + +export default { + layout: 'default', + middleware: 'authenticated', + components: { + Context + }, + computed: { + ...mapGetters({ + isCollapsed: 'sidebar/isCollapsed', + name: 'auth/getName', + nameAbbreviation: 'auth/getNameAbbreviation' + }) + }, + methods: { + logoff() { + this.$store.dispatch('auth/logoff') + this.$nuxt.$router.replace({ name: 'login' }) + }, + ...mapActions({ + toggleCollapsed: 'sidebar/toggleCollapsed' + }) + } +} +</script> diff --git a/web-frontend/layouts/default.vue b/web-frontend/layouts/default.vue deleted file mode 100644 index 984257528..000000000 --- a/web-frontend/layouts/default.vue +++ /dev/null @@ -1,5 +0,0 @@ -<template> - <div> - <nuxt /> - </div> -</template> diff --git a/web-frontend/pages/app/index.vue b/web-frontend/pages/app/index.vue index d0a7363c8..d3f9e4b01 100644 --- a/web-frontend/pages/app/index.vue +++ b/web-frontend/pages/app/index.vue @@ -8,7 +8,7 @@ import { mapState } from 'vuex' export default { - middleware: 'authenticated', + layout: 'app', computed: { ...mapState({ user: state => state.auth.user diff --git a/web-frontend/plugins/directives.js b/web-frontend/plugins/directives.js new file mode 100644 index 000000000..64a9c8c3f --- /dev/null +++ b/web-frontend/plugins/directives.js @@ -0,0 +1,7 @@ +import Vue from 'vue' + +import moveToBody from '@/directives/moveToBody' +import clickOutside from '@/directives/clickOutside' + +Vue.directive('move-to-body', moveToBody) +Vue.directive('click-outside', clickOutside) diff --git a/web-frontend/store/auth.js b/web-frontend/store/auth.js index 86dca60db..ceb43ab44 100644 --- a/web-frontend/store/auth.js +++ b/web-frontend/store/auth.js @@ -49,6 +49,14 @@ export const actions = { } ) }, + /** + * Logs off the user by removing the token as a cookie and clearing the user + * data. + */ + logoff({ commit }) { + unsetToken(this.app.$cookies) + commit('CLEAR_USER_DATA') + }, /** * Refresh the existing token. If successful commit the new token and start a * new refresh timeout. If unsuccessful the existing cookie and user data is @@ -100,10 +108,19 @@ export const getters = { token(state) { return state.token }, + getName(state) { + return state.user ? state.user.first_name : '' + }, + getNameAbbreviation(state) { + return state.user ? state.user.first_name.split('')[0] : '' + }, + getEmail(state) { + return state.user ? state.user.email : '' + }, /** * Returns the amount of seconds it will take before the tokes expires. - * @TODO figure out what happens if the browser and server time and not very - * much in sync. + * @TODO figure out what happens if the browser and server time are not in + * sync. */ tokenExpireSeconds(state) { const now = Math.ceil(new Date().getTime() / 1000) diff --git a/web-frontend/store/sidebar.js b/web-frontend/store/sidebar.js new file mode 100644 index 000000000..0f3a33f8a --- /dev/null +++ b/web-frontend/store/sidebar.js @@ -0,0 +1,24 @@ +export const state = () => ({ + collapsed: false +}) + +export const mutations = { + SET_COLLAPSED(state, collapsed) { + state.collapsed = collapsed + } +} + +export const actions = { + toggleCollapsed({ commit, getters }, value) { + if (value === undefined) { + value = !getters.isCollapsed + } + commit('SET_COLLAPSED', value) + } +} + +export const getters = { + isCollapsed(state) { + return !!state.collapsed + } +} diff --git a/web-frontend/utils/dom.js b/web-frontend/utils/dom.js new file mode 100644 index 000000000..882fdc2da --- /dev/null +++ b/web-frontend/utils/dom.js @@ -0,0 +1,9 @@ +/** + * Checks if the target is the same as the provided element of that the element + * contains the target. Returns true is this is the case. + * + * @returns boolean + */ +export const isElement = (element, target) => { + return element === target || element.contains(target) +}