@oxygen-cms/ui 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/.babelrc +1 -0
  2. package/.eslintrc.js +22 -0
  3. package/.github/workflows/node.js.yml +29 -0
  4. package/.idea/modules.xml +8 -0
  5. package/.idea/ui.iml +10 -0
  6. package/.jshintrc +3 -0
  7. package/README.md +7 -0
  8. package/assets/oxygen-icon.png +0 -0
  9. package/jest.init.js +1 -0
  10. package/package.json +72 -0
  11. package/src/AuthApi.js +116 -0
  12. package/src/CrudApi.js +112 -0
  13. package/src/EventsApi.js +16 -0
  14. package/src/GroupsApi.js +9 -0
  15. package/src/Internationalize.js +31 -0
  16. package/src/MediaApi.js +52 -0
  17. package/src/MediaDirectoryApi.js +62 -0
  18. package/src/PreferencesApi.js +47 -0
  19. package/src/UserPermissions.js +66 -0
  20. package/src/UserPreferences.js +69 -0
  21. package/src/UserPreferences.test.js +23 -0
  22. package/src/UsersApi.js +41 -0
  23. package/src/api.js +209 -0
  24. package/src/components/App.vue +61 -0
  25. package/src/components/AuthenticatedLayout.vue +254 -0
  26. package/src/components/AuthenticationLog.vue +196 -0
  27. package/src/components/CodeEditor.vue +90 -0
  28. package/src/components/EditButtonOnRowHover.vue +21 -0
  29. package/src/components/Error404.vue +25 -0
  30. package/src/components/EventsChooser.vue +88 -0
  31. package/src/components/EventsTable.vue +82 -0
  32. package/src/components/GenericEditableField.vue +74 -0
  33. package/src/components/GroupsChooser.vue +58 -0
  34. package/src/components/GroupsList.vue +129 -0
  35. package/src/components/ImportExport.vue +45 -0
  36. package/src/components/LegacyPage.vue +256 -0
  37. package/src/components/UserJoined.vue +35 -0
  38. package/src/components/UserManagement.vue +168 -0
  39. package/src/components/UserProfileForm.vue +214 -0
  40. package/src/components/ViewProfile.vue +32 -0
  41. package/src/components/auth/Auth404.vue +16 -0
  42. package/src/components/auth/Login.vue +135 -0
  43. package/src/components/auth/LoginLogo.vue +30 -0
  44. package/src/components/auth/Logout.vue +26 -0
  45. package/src/components/auth/PasswordRemind.vue +71 -0
  46. package/src/components/auth/PasswordReset.vue +97 -0
  47. package/src/components/auth/TwoFactorSetup.vue +115 -0
  48. package/src/components/auth/VerifyEmail.vue +71 -0
  49. package/src/components/auth/WelcomeFloat.vue +87 -0
  50. package/src/components/auth/login.scss +17 -0
  51. package/src/components/media/MediaChooseDirectory.vue +129 -0
  52. package/src/components/media/MediaDirectory.vue +109 -0
  53. package/src/components/media/MediaInsertModal.vue +88 -0
  54. package/src/components/media/MediaItem.vue +282 -0
  55. package/src/components/media/MediaItemPreview.vue +45 -0
  56. package/src/components/media/MediaList.vue +305 -0
  57. package/src/components/media/MediaPage.vue +44 -0
  58. package/src/components/media/MediaResponsiveImages.vue +51 -0
  59. package/src/components/media/MediaUpload.vue +133 -0
  60. package/src/components/media/media.scss +51 -0
  61. package/src/components/preferences/PreferencesAdminAppearance.vue +22 -0
  62. package/src/components/preferences/PreferencesAuthentication.vue +27 -0
  63. package/src/components/preferences/PreferencesEventTemplates.vue +22 -0
  64. package/src/components/preferences/PreferencesField.vue +215 -0
  65. package/src/components/preferences/PreferencesList.vue +50 -0
  66. package/src/components/preferences/PreferencesPageTemplates.vue +23 -0
  67. package/src/components/preferences/PreferencesSiteAppearance.vue +22 -0
  68. package/src/components/preferences/PreferencesThemeChooser.vue +73 -0
  69. package/src/components/preferences/ShowIfPermitted.vue +37 -0
  70. package/src/components/preferences/UserPreferences.vue +30 -0
  71. package/src/components/users/CreateUserModal.vue +73 -0
  72. package/src/components/util.css +47 -0
  73. package/src/icons.js +90 -0
  74. package/src/main.js +112 -0
  75. package/src/modules/LegacyPages.js +18 -0
  76. package/src/modules/Media.js +45 -0
  77. package/src/modules/UserManagement.js +24 -0
  78. package/src/routes/index.js +92 -0
  79. package/src/store/index.js +70 -0
  80. package/src/styles/_variables.scss +23 -0
  81. package/src/styles/app.scss +76 -0
  82. package/src/unsavedChanges.js +16 -0
  83. package/src/util.js +65 -0
  84. package/src/util.test.js +39 -0
@@ -0,0 +1,69 @@
1
+ // ================================
2
+ // Notification
3
+ // ================================
4
+
5
+ import AuthApi from "./AuthApi";
6
+
7
+ const isDefined = (o) => {
8
+ return typeof o !== 'undefined' && o !== null;
9
+ };
10
+
11
+ class UserPreferences {
12
+
13
+ constructor(preferences) {
14
+ this.preferences = preferences;
15
+ }
16
+
17
+ static setBuefy($buefy) {
18
+ this.$buefy = $buefy;
19
+ this.authApi = new AuthApi(this.$buefy);
20
+ }
21
+
22
+ get(key, fallback = null) {
23
+ let o = this.preferences;
24
+
25
+ if(!isDefined(o)) {
26
+ return fallback;
27
+ }
28
+
29
+ var parts = key.split('.');
30
+ //var last = parts.pop();
31
+ var l = parts.length;
32
+ var i = 0;
33
+
34
+ while(isDefined(o) && i < l) {
35
+ var idx = parts[i];
36
+ o = o[idx];
37
+ i++;
38
+ }
39
+
40
+ if (isDefined(o)) {
41
+ return o;
42
+ } else {
43
+ console.log('Preferences key ', key, 'was not defined, using default ', fallback);
44
+ return fallback;
45
+ }
46
+ }
47
+
48
+ has(key) {
49
+ let o = this.preferences;
50
+ if(!o) {
51
+ return false;
52
+ }
53
+
54
+ var parts = key.split('.');
55
+ var l = parts.length;
56
+ var i = 1;
57
+ var current = parts[0];
58
+
59
+ while((o = o[current]) && i < l) {
60
+ current = parts[i];
61
+ i++;
62
+ }
63
+
64
+ return !!o;
65
+ }
66
+ }
67
+
68
+ export default UserPreferences;
69
+
@@ -0,0 +1,23 @@
1
+ import UserPreferences from './UserPreferences';
2
+
3
+ jest.mock('./AuthApi');
4
+
5
+ test('gets and sets preferences', async () => {
6
+ UserPreferences.setBuefy({});
7
+ UserPreferences.authApi.userDetails.mockResolvedValue({
8
+ user: {
9
+ preferences: {
10
+ foo: 'bar',
11
+ baz: { qux: 'fub '}
12
+ }
13
+ }
14
+ });
15
+
16
+ let prefs = await UserPreferences.load();
17
+
18
+ expect(prefs.get('foo')).toBe('bar');
19
+ expect(prefs.has('fob')).toBe(false);
20
+ expect(prefs.has('baz.qux')).toBe(true);
21
+ expect(prefs.get('bar.qux2', 'fallback')).toBe('fallback');
22
+
23
+ });
@@ -0,0 +1,41 @@
1
+ import {API_ROOT, CrudApi} from './CrudApi';
2
+
3
+ export default class UsersApi extends CrudApi {
4
+
5
+ static prepareModelForAPI(data) {
6
+ let m = { ...data };
7
+ delete m.id;
8
+ if(m.group) {
9
+ m.group = m.group.id;
10
+ }
11
+ return m;
12
+ }
13
+
14
+ static getResourceName() {
15
+ return 'users';
16
+ }
17
+
18
+ async updateFullName(id, name) {
19
+ return this.request('put')
20
+ .withJson({
21
+ fullName: name
22
+ })
23
+ .fetch(API_ROOT + 'users/' + id + '/fullName');
24
+ }
25
+
26
+ async impersonate(id) {
27
+ return await this.request('post')
28
+ .fetch(API_ROOT + 'users/' + id + '/impersonate');
29
+ }
30
+
31
+ async forceDelete(id) {
32
+ return this.request('delete')
33
+ .fetch(this.constructor.getResourceRoot() + '/' + id + '/force');
34
+ }
35
+
36
+ async stopImpersonating() {
37
+ return await this.request('post')
38
+ .fetch(API_ROOT + 'users/stop-impersonating');
39
+ }
40
+
41
+ }
package/src/api.js ADDED
@@ -0,0 +1,209 @@
1
+ var xsrfToken = null;
2
+
3
+ export const initCsrfCookie = async () => {
4
+ await window.fetch(
5
+ '/sanctum/csrf-cookie',
6
+ {
7
+ credentials: 'same-origin'
8
+ });
9
+ let cookiesObj = document.cookie
10
+ .split(';')
11
+ .reduce((res, c) => {
12
+ const [key, val] = c.trim().split('=').map(decodeURIComponent)
13
+ return Object.assign(res, { [key]: val });
14
+ }, {});
15
+ xsrfToken = cookiesObj['XSRF-TOKEN'];
16
+ }
17
+
18
+ export class FetchBuilder {
19
+ constructor($buefy, method) {
20
+ this.$buefy = $buefy;
21
+ this.method = method;
22
+ this.headers = new Headers();
23
+ this.body = undefined;
24
+ }
25
+ wantJson() {
26
+ this.headers.set('Accept', 'application/json');
27
+ return this;
28
+ }
29
+ setContentType(type) {
30
+ this.headers.set('Content-Type', type);
31
+ return this;
32
+ }
33
+ setBody(body) {
34
+ this.body = body;
35
+ return this;
36
+ }
37
+ withJson(json) {
38
+ this.body = JSON.stringify(json);
39
+ this.setContentType('application/json');
40
+ return this;
41
+ }
42
+
43
+ withQueryParams(queryParams) {
44
+ this.queryParams = queryParams;
45
+ return this;
46
+ }
47
+
48
+ cookies() {
49
+ this.credentials = 'same-origin';
50
+ return this;
51
+ }
52
+
53
+ async setXsrfTokenHeader() {
54
+ if(xsrfToken === null) {
55
+ await initCsrfCookie();
56
+ }
57
+ this.headers.set('X-XSRF-TOKEN', xsrfToken);
58
+ }
59
+
60
+ async fetch(url) {
61
+ await this.setXsrfTokenHeader()
62
+
63
+ let v = { ... this};
64
+ v.queryParams = undefined;
65
+
66
+ if(this.queryParams) {
67
+ url = new URL(url, window.location);
68
+ for(let name in this.queryParams) {
69
+ if(Object.prototype.hasOwnProperty.call(this.queryParams, name) && this.queryParams[name] !== null) {
70
+ url.searchParams.append(name, this.queryParams[name]);
71
+ }
72
+ }
73
+ }
74
+
75
+ let response = await window.fetch(url.toString(), this);
76
+
77
+ let data = {};
78
+ try {
79
+ data = await response.json();
80
+ } catch(e) {
81
+ if(response.status === 413) {
82
+ this.$buefy.notification.open({
83
+ message: 'Upload failed: file(s) too large',
84
+ type: 'is-danger',
85
+ animation: 'fade',
86
+ queue: false
87
+ });
88
+ return {};
89
+ } else if(response.status === 204) {
90
+ // no content, we're okay
91
+ } else {
92
+ console.error('Response did not contain valid JSON: ', e);
93
+ this.$buefy.notification.open({
94
+ message: 'Whoops, looks like something went wrong.',
95
+ type: 'is-warning',
96
+ queue: false
97
+ });
98
+
99
+ throw e;
100
+ }
101
+ }
102
+
103
+ if(response.ok && data.status !== 'failed') {
104
+ return data;
105
+ }
106
+
107
+ handleAPIError(data, this.$buefy, FetchBuilder.router, response);
108
+ let e = new Error('Received an error response from API call');
109
+ e.response = data;
110
+ throw e;
111
+ }
112
+
113
+ static default($buefy, method) {
114
+ return (new FetchBuilder($buefy, method))
115
+ .cookies()
116
+ .wantJson();
117
+ }
118
+
119
+ static setRouter(router) {
120
+ FetchBuilder.router = router;
121
+ }
122
+ }
123
+
124
+ function statusToBueify(status) {
125
+ if(status === 'failed') {
126
+ return 'is-danger';
127
+ } else {
128
+ return `is-${status}`;
129
+ }
130
+ }
131
+
132
+ export function morphToNotification(data) {
133
+ return {
134
+ message: data.content,
135
+ type: statusToBueify(data.status),
136
+ indefinite: data.duration === 'indefinite',
137
+ duration: data.duration ? data.duration : 4000,
138
+ queue: false
139
+ };
140
+ }
141
+
142
+ const handleAPIError = function(content, $buefy, $router, response) {
143
+ console.error('API error: ', content);
144
+ if(response.status === 401 && content.code === 'unauthenticated') {
145
+ // server is telling us to login again
146
+ initCsrfCookie()
147
+ .then(() => {
148
+ $router.push({path: '/auth/login', query: {redirect: $router.currentRoute.fullPath}});
149
+ });
150
+ return;
151
+ } else if(response.status === 403 && content.code === 'two_factor_setup_required') {
152
+ $router.push({ path: '/auth/2fa-setup' });
153
+ return;
154
+ } else if(response.status === 403 && content.code === 'email_unverified') {
155
+ $router.push({ path: '/auth/needs-verified-email', query: {redirect: $router.currentRoute.fullPath } });
156
+ return;
157
+ } else if(response.status === 429) {
158
+ $buefy.notification.open({
159
+ message: 'Too many requests within a short timeframe. Please wait.',
160
+ type: 'is-warning',
161
+ duration: 10000,
162
+ queue: false
163
+ });
164
+ return;
165
+ }
166
+
167
+ // handle generic validation errors
168
+ if(typeof content.errors === 'object') {
169
+ for(const [, errors ] of Object.entries(content.errors)) {
170
+ for(let error of errors) {
171
+ $buefy.notification.open({
172
+ message: error,
173
+ duration: 4000,
174
+ queue: false,
175
+ type: 'is-warning'
176
+ });
177
+ }
178
+ }
179
+ return;
180
+ }
181
+
182
+ if(content.content && content.status) {
183
+ $buefy.notification.open(morphToNotification(content));
184
+ } else if(content.exception) {
185
+ $buefy.notification.open({
186
+ message:
187
+ 'PHP Exception of type <pre class="no-pre">' + content.exception +
188
+ '</pre> with message <pre class="no-pre">' + content.message +
189
+ '</pre> thrown at <pre class="no-pre">' + content.file + ':' + content.line +
190
+ '</pre>',
191
+ duration: 20000,
192
+ animation: 'fade',
193
+ type: 'is-danger'
194
+ });
195
+ } else if(response.status === 500) {
196
+ $buefy.notification.open({
197
+ message:'Whoops, looks like something went wrong.',
198
+ type: 'is-danger',
199
+ animation: 'fade',
200
+ queue: false
201
+ });
202
+ }
203
+ };
204
+
205
+ export function getXsrfToken() {
206
+ return xsrfToken;
207
+ }
208
+
209
+ FetchBuilder.router = null;
@@ -0,0 +1,61 @@
1
+ <template>
2
+ <div style="height: 100%;">
3
+ <!-- <transition name="slide-left" mode="out-in">-->
4
+ <router-view @logout="signOut">
5
+ <template #main-navigation>
6
+ <slot name="app-navigation"></slot>
7
+ </template>
8
+ </router-view>
9
+ <!-- </transition>-->
10
+ </div>
11
+ </template>
12
+
13
+ <script>
14
+ import AuthApi from "../AuthApi";
15
+ export default {
16
+ name: "App",
17
+ props: {
18
+ appTitle: { type: String, required: true },
19
+ defaultRouteTitle: { type: String, required: true },
20
+ impersonating: {
21
+ type: Boolean,
22
+ default: false
23
+ }
24
+ },
25
+ data() {
26
+ return {
27
+ user: null,
28
+ authApi: new AuthApi(this.$buefy),
29
+ userPermissions: null
30
+ }
31
+ },
32
+ watch: {
33
+ '$route' (to) {
34
+ this.setTitle(to.meta.title);
35
+ },
36
+ },
37
+ created() {
38
+ this.setTitle(this.$route.meta.title);
39
+ this.$root.$on('update-route-title', (title) => {
40
+ this.setTitle(title);
41
+ });
42
+ },
43
+ methods: {
44
+ setTitle(title) {
45
+ document.title = (title || this.defaultRouteTitle) + ' - ' + this.appTitle;
46
+ },
47
+ async signOut() {
48
+ await this.authApi.logout();
49
+ this.$store.commit('setUser', null);
50
+ await this.$router.push('/auth/logout');
51
+ },
52
+ stopImpersonating() {
53
+ this.authApi.stopImpersonating();
54
+ }
55
+ }
56
+ }
57
+ </script>
58
+
59
+ <style lang="scss">
60
+ @import '../styles/app.scss';
61
+ </style>
@@ -0,0 +1,254 @@
1
+ <template>
2
+ <div class="is-flex is-flex-direction-row" style="height: 100%;">
3
+ <div :class="'left-navigation-container' + (collapsed ? ' is-collapsed' : '')">
4
+
5
+ <div class="app-logo-title">
6
+ <router-link to="/" class="app-logo-title-link">
7
+ <img src="../../assets/oxygen-icon.png" alt="Oxygen CMS" class="app-logo">
8
+ <router-link v-if="!collapsed" to="/" class="app-title">Oxygen CMS</router-link>
9
+ </router-link>
10
+ <span class="is-flex-grow-1"></span>
11
+ <b-button v-if="!requestedCollapsed" type="is-light" :icon-left="collapsed ? 'angle-right' : 'angle-left'" class="collapse-menu-button" @click="setCollapsed = !setCollapsed"></b-button>
12
+ </div>
13
+
14
+ <b-menu class="left-navigation">
15
+ <slot name="main-navigation" :collapsed="collapsed"></slot>
16
+ </b-menu>
17
+
18
+ <div :class="'user-info' + (impersonating ? ' has-background-warning' : '')">
19
+ <b-dropdown aria-role="list" :position="collapsed ? 'is-top-right' : 'is-top-left'" expanded>
20
+ <template #trigger>
21
+ <div class="user-dropdown">
22
+ <div v-if="!collapsed" class="user-dropdown-text">
23
+ <strong v-if="impersonating">Temporarily logged-in as<br/></strong>
24
+ <transition name="fade" mode="out-in">
25
+ <span v-if="user">{{ user.fullName }}</span>
26
+ <b-skeleton v-else size="is-medium" width="10em" :animated="true"></b-skeleton>
27
+ </transition>
28
+ <transition name="fade" mode="out-in">
29
+ <p v-if="user" class="is-size-7">{{ user.email }}</p>
30
+ <b-skeleton v-else width="8em" :animated="true"></b-skeleton>
31
+ </transition>
32
+ </div>
33
+ <div class="is-flex-grow-1"></div>
34
+ <div class="has-background-grey-light centered-icon" style="display: inline-block;">
35
+ <b-icon icon="user" size="is-large" class="has-text-grey-lighter"></b-icon>
36
+ </div>
37
+ </div>
38
+ </template>
39
+ <b-dropdown-item aria-role="listitem" has-link><router-link to="/user/profile"><b-icon icon="user-edit"></b-icon>Profile</router-link></b-dropdown-item>
40
+ <b-dropdown-item aria-role="listitem" has-link><router-link to="/user/login-log"><b-icon icon="lock"></b-icon>Account Security</router-link></b-dropdown-item>
41
+ <b-dropdown-item separator></b-dropdown-item>
42
+ <b-dropdown-item aria-role="listitem" @click="$emit('logout')"><b-icon icon="sign-out-alt"></b-icon>Sign Out</b-dropdown-item>
43
+ <b-dropdown-item v-if="impersonating" aria-role="listitem" @click="stopImpersonating"><b-icon icon="times"></b-icon>Stop impersonating</b-dropdown-item>
44
+ </b-dropdown>
45
+ </div>
46
+ </div>
47
+
48
+ <div class="no-pad full-height-container content-column">
49
+
50
+ <transition name="slide-up" mode="out-in">
51
+ <router-view></router-view>
52
+ </transition>
53
+
54
+ </div>
55
+ </div>
56
+ </template>
57
+
58
+ <script>
59
+ import UsersApi from "../UsersApi";
60
+ import {morphToNotification} from "../api";
61
+ import {isNavigationFailure, NavigationFailureType} from "vue-router/src/util/errors";
62
+
63
+ export default {
64
+ name: "AuthenticatedLayout",
65
+ emits: ["logout"],
66
+ data() {
67
+ return {
68
+ usersApi: new UsersApi(this.$buefy),
69
+ setCollapsed: false,
70
+ requestedCollapsed: false
71
+ }
72
+ },
73
+ computed: {
74
+ impersonating() { return this.$store.state.impersonating; },
75
+ user() { return this.$store.state.user; },
76
+ userPreferences() { return this.$store.getters.userPreferences; },
77
+ collapsed() {
78
+ return this.setCollapsed || this.requestedCollapsed;
79
+ }
80
+ },
81
+ mounted() {
82
+ console.log('mounted', this.$store);
83
+ this.setGlobalFontSize()
84
+ },
85
+ methods: {
86
+ setGlobalFontSize() {
87
+ let fontSize = this.userPreferences.get('fontSize', '100%');
88
+ console.log('Setting global font size to ', fontSize);
89
+ if(fontSize !== '100%') {
90
+ window.document.documentElement.style.fontSize = fontSize;
91
+ }
92
+ },
93
+ async stopImpersonating() {
94
+ let response = await this.usersApi.stopImpersonating();
95
+ this.$buefy.notification.open(morphToNotification(response));
96
+ this.$store.commit('stopImpersonating', response.user);
97
+ // ignore duplicated navigation failure
98
+ await this.$router.push({ path: '/' }).catch(failure => {
99
+ if(!isNavigationFailure(failure, NavigationFailureType.duplicated)) {
100
+ throw failure;
101
+ }
102
+ });
103
+ }
104
+ }
105
+ }
106
+ </script>
107
+
108
+ <style scoped lang="scss">
109
+ @import 'util.css';
110
+ @import '../styles/_variables.scss';
111
+
112
+ .left-navigation-container {
113
+ padding: 0 !important;
114
+ display: flex;
115
+ flex-direction: column;
116
+ flex: 1;
117
+ max-width: 550px;
118
+ border-right: 1px solid $grey-lighter;
119
+ transition: max-width 0.5s ease;
120
+ }
121
+
122
+ .left-navigation-container.is-collapsed {
123
+ max-width: 5rem;
124
+ }
125
+
126
+ .app-logo-title {
127
+ display: flex;
128
+ align-items: center;
129
+ padding: 1rem 0;
130
+ }
131
+
132
+ .app-logo {
133
+ width: 3rem;
134
+ margin-left: 1rem;
135
+ margin-right: 1rem;
136
+ }
137
+
138
+ .app-logo-title-link {
139
+ display: flex;
140
+ align-items: center;
141
+ }
142
+
143
+ .app-title {
144
+ color: $text;
145
+ font-size: 1.1rem;
146
+ }
147
+
148
+ .app-container {
149
+ display: flex;
150
+ flex-direction: row;
151
+ }
152
+
153
+ .left-navigation {
154
+ flex: 1;
155
+ padding: 1rem 1rem 1rem 2rem;
156
+ overflow-y: auto;
157
+ overflow-x: hidden;
158
+ }
159
+
160
+ .content-column {
161
+ flex: 4;
162
+ }
163
+
164
+ ::v-deep .router-link-exact-active {
165
+ // background-color: $link-invert !important;
166
+ color: $black-bis !important;
167
+ font-weight: 700;
168
+ }
169
+
170
+ ::v-deep .menu-list .icon {
171
+ margin-right: 1rem;
172
+ }
173
+
174
+ .user-dropdown {
175
+ padding: 1rem;
176
+ cursor: pointer;
177
+ display: flex;
178
+ align-items: center;
179
+ }
180
+
181
+ .columns {
182
+ margin: 0;
183
+ }
184
+
185
+ .no-pad {
186
+ padding: 0;
187
+ }
188
+
189
+ .content-column {
190
+ background-color: $white-ter;
191
+ }
192
+
193
+ .dropdown-content .icon {
194
+ margin-right: 0.5rem;
195
+ }
196
+
197
+ .app-logo-title:hover .collapse-menu-button {
198
+ opacity: 1.0;
199
+ }
200
+
201
+ .collapse-menu-button {
202
+ opacity: 0;
203
+ transition: opacity 0.2s ease;
204
+ margin-right: 1rem;
205
+ z-index: 10;
206
+ }
207
+
208
+ .is-collapsed {
209
+ .collapse-menu-button {
210
+ margin-left: 1rem;
211
+ }
212
+
213
+ .left-navigation {
214
+ padding: 0;
215
+ text-align: center;
216
+ }
217
+
218
+ ::v-deep .menu-label {
219
+ text-indent: -9999px;
220
+ height: 0;
221
+ border-bottom: 1px solid $grey-lighter;
222
+ }
223
+
224
+ ::v-deep .icon-text > span:not(.icon) {
225
+ display: none;
226
+ }
227
+
228
+ ::v-deep .menu-list .icon {
229
+ margin-right: 0;
230
+ }
231
+
232
+ ::v-deep .menu-list li ul {
233
+ border-left: 0;
234
+ margin: 0;
235
+ padding-left: 0;
236
+ }
237
+
238
+ .app-logo-title-link {
239
+ width: 5rem;
240
+ flex-direction: column;
241
+ }
242
+
243
+ .app-logo-title {
244
+ display: block;
245
+ position: relative;
246
+ }
247
+
248
+ .collapse-menu-button {
249
+ position: absolute;
250
+ top: 1rem;
251
+ left: 4.5rem;
252
+ }
253
+ }
254
+ </style>