Merge pull request 'comptability with nc25' (#137) from develop into master
Some checks are pending
ci/woodpecker/push/woodpecker Pipeline is pending
ci/woodpecker/tag/woodpecker Pipeline was successful

Reviewed-on: #137
This commit is contained in:
Simon Vieille 2022-10-17 18:11:24 +02:00
commit 8e5193417d
32 changed files with 1601 additions and 1634 deletions

1
.gitignore vendored
View file

@ -4,3 +4,4 @@
/releases /releases
/package-lock.json /package-lock.json
!/l10n/.gitkeep !/l10n/.gitkeep
/yarn*.log

View file

@ -1,5 +1,5 @@
{ {
"rules": { "rules": {
"indentation": 4 "indentation": 2
} }
} }

View file

@ -1,21 +1,21 @@
pipeline: pipeline:
dependencies: dependencies:
image: deblan/devenv image: gitnet.fr/deblan/devenv
commands: commands:
- npm install - make dep
when: when:
event: [tag, push, pull_request] event: [tag, push, pull_request]
branch: [master, develop, feature/*] branch: [master, develop, feature/*]
build: build:
image: deblan/devenv image: gitnet.fr/deblan/devenv
commands: commands:
- make npm-build - make build
when: when:
event: [push, pull_request] event: [push, pull_request]
package: package:
image: deblan/devenv image: gitnet.fr/deblan/devenv
volumes: volumes:
- /var/www/html/artifacts:/var/www/html/artifacts - /var/www/html/artifacts:/var/www/html/artifacts
secrets: [app_certificate] secrets: [app_certificate]

View file

@ -1,10 +1,16 @@
## [Unreleased] ## [Unreleased]
## 3.0.0
### Added
* Add compatibility with NC25 (#136/#135)
### Removed
* Nextcloud 20-24 are not supported anymore
* AppOrder is not supported anymore
## 2.5.1 ## 2.5.1
### Fixed ### Fixed
* fix icon render (#133) * fix icon render (#133)
## 2.5.0 ## 2.5.0
### Changed ### Changed
* upgrade dependencies * upgrade dependencies

View file

@ -1,11 +1,15 @@
npm-build: build: dep
npm run build npm run build
npm-watch: watch: dep
npm run watch npm run watch
dep:
npm i
npm link @nextcloud/vue || sudo npm link @nextcloud/vue
.ONESHELL: .ONESHELL:
release: npm-build translations release: build translations
if [ -z "$$VERSION" ]; then if [ -z "$$VERSION" ]; then
echo "VERSION required" echo "VERSION required"
exit 1 exit 1

View file

@ -56,9 +56,9 @@ If you are a developer:
* fork the repository * fork the repository
* install an instance of Nextcloud * install an instance of Nextcloud
* go to `apps/` and clone your repository * go to `apps/` and clone your repository
* go to `apps/side_menu` and run `npm install` * go to `apps/side_menu` and run `make dep`
Build javascripts using `make npm-build` (or `make npm-watch` to build them in real time). Build javascripts using `make build` (or `make watch` to build them in real time).
Then commit and create a pull request. Then commit and create a pull request.

View file

@ -11,7 +11,7 @@ This application is rather suitable for instances that activate a lot of applica
Use the shortcut `Ctrl`+`o` to open and to hide the side menu. Use `tab` to navigate. Use the shortcut `Ctrl`+`o` to open and to hide the side menu. Use `tab` to navigate.
You can customize colors depending of the theme (Dark theme and Breeze Dark). Comptatible with AppOrder. You can customize colors depending of the theme (Dark theme and Breeze Dark).
You can report a bug or request a feature by opening an issue. You can report a bug or request a feature by opening an issue.
@ -26,7 +26,7 @@ If you like this application and if you want to support the development:
* [Donate with liberapay](https://liberapay.com/deblan) * [Donate with liberapay](https://liberapay.com/deblan)
* [Leave a comment](https://apps.nextcloud.com/apps/side_menu#comments) * [Leave a comment](https://apps.nextcloud.com/apps/side_menu#comments)
]]></description> ]]></description>
<version>2.5.1</version> <version>3.0.0</version>
<licence>agpl</licence> <licence>agpl</licence>
<author mail="contact@deblan.fr" homepage="https://www.deblan.io/">Simon Vieille</author> <author mail="contact@deblan.fr" homepage="https://www.deblan.io/">Simon Vieille</author>
<namespace>SideMenu</namespace> <namespace>SideMenu</namespace>

View file

@ -101,7 +101,6 @@
width: 100%; width: 100%;
} }
.side-menu-setting-table { .side-menu-setting-table {
display: table; display: table;
width: 100%; width: 100%;
@ -140,7 +139,7 @@
.btn-reset { .btn-reset {
display: inline-block; display: inline-block;
cursor: pointer; cursor: pointer;
position: absolute; position: relative;
margin-top: 17px; top: -8px;
margin-left: 5px; left: 5px;
} }

View file

@ -21,7 +21,7 @@
left: 0; left: 0;
height: 100vh; height: 100vh;
width: 100%; width: 100%;
max-width: 250px; max-width: 290px;
background: linear-gradient(90deg, var(--side-menu-background-color, #333) 0%, var(--side-menu-background-color-to, #333) 100%); background: linear-gradient(90deg, var(--side-menu-background-color, #333) 0%, var(--side-menu-background-color-to, #333) 100%);
z-index: 3000; z-index: 3000;
color: var(--side-menu-text-color, #fff); color: var(--side-menu-text-color, #fff);
@ -103,27 +103,19 @@
position: fixed; position: fixed;
top: 150px; top: 150px;
width: 100%; width: 100%;
max-width: 250px; max-width: 290px;
overflow: auto; overflow: auto;
} }
.side-menu-app-icon { .side-menu-app-icon {
width: 20px; width: 20px;
vertical-align: top; vertical-align: middle;
margin-top: -4px;
margin-right: 10px; margin-right: 10px;
filter: invert(var(--side-menu-icon-invert-filter, 0%)); filter: invert(var(--side-menu-icon-invert-filter, 0%));
opacity: var(--side-menu-icon-opacity, 1); opacity: var(--side-menu-icon-opacity, 1);
} }
.side-menu-app-icon svg {
vertical-align: middle;
margin-top: -3px;
}
.side-menu-app-icon .app-icon-notification {
display: none;
}
.side-menu-app a { .side-menu-app a {
line-height: 30px; line-height: 30px;
color: var(--side-menu-text-color, #fff); color: var(--side-menu-text-color, #fff);
@ -147,11 +139,11 @@
max-height: 100px; max-height: 100px;
} }
.side-menu-header { .enu-header {
height: 150px; height: 150px;
width: 100%; width: 100%;
z-index: 2300; z-index: 2300;
max-width: 250px; max-width: 290px;
position: fixed; position: fixed;
padding-top: 2px; padding-top: 2px;
top: 0; top: 0;
@ -240,7 +232,6 @@
margin-top: -2px; margin-top: -2px;
} }
.side-menu-always-displayed #header,
.side-menu-always-displayed body { .side-menu-always-displayed body {
width: calc(100% - 50px) !important; width: calc(100% - 50px) !important;
} }
@ -312,6 +303,14 @@
overflow-x: visible; overflow-x: visible;
} }
.app-menu {
visibility: hidden;
}
.app-menu.show {
visibility: visible;
}
@media screen and (max-width: 1024px) { @media screen and (max-width: 1024px) {
#side-menu.side-menu-big { #side-menu.side-menu-big {
max-width: 290px; max-width: 290px;

View file

@ -2,17 +2,15 @@
"license": "agpl", "license": "agpl",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "NODE_ENV=production webpack --progress --config webpack.js", "build": "NODE_ENV=production ./node_modules/.bin/webpack-cli --progress --config webpack.js",
"dev": "NODE_ENV=development webpack --progress --config webpack.js", "dev": "NODE_ENV=development ./node_modules/.bin/webpack-cli --progress --config webpack.js",
"watch": "NODE_ENV=development webpack --progress --watch --config webpack.js", "watch": "NODE_ENV=development ./node_modules/.bin/webpack-cli --progress --watch --config webpack.js",
"lint": "eslint --ext .js,.vue src", "lint": "./node_modules/.bin/eslint --ext .js,.vue src",
"lint:fix": "eslint --ext .js,.vue src --fix", "lint:fix": "./node_modules/.bin/eslint --ext .js,.vue src --fix",
"stylelint": "stylelint src", "stylelint": "./node_modules/.bin/stylelint src",
"stylelint:fix": "stylelint src --fix" "stylelint:fix": "./node_modules/.bin/stylelint src --fix"
}, },
"dependencies": { "dependencies": {
"@nextcloud/axios": "^1.8.0",
"@nextcloud/vue": "^1.5.0",
"axios": "^0.24.0", "axios": "^0.24.0",
"trim": "^1.0.1", "trim": "^1.0.1",
"vue": "^2.6.11" "vue": "^2.6.11"
@ -27,8 +25,12 @@
"@babel/core": "^7.9.0", "@babel/core": "^7.9.0",
"@babel/plugin-syntax-dynamic-import": "^7.8.3", "@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/preset-env": "^7.9.0", "@babel/preset-env": "^7.9.0",
"@nextcloud/axios": "^1.8.0",
"@nextcloud/browserslist-config": "^1.0.0", "@nextcloud/browserslist-config": "^1.0.0",
"@nextcloud/eslint-config": "^8.1.2", "@nextcloud/eslint-config": "^8.1.2",
"@nextcloud/initial-state": "^2.0.0",
"@nextcloud/l10n": "^1.6.0",
"@nextcloud/vue": "^7.0.0",
"babel-eslint": "^10.1.0", "babel-eslint": "^10.1.0",
"babel-loader": "^8.1.0", "babel-loader": "^8.1.0",
"css-loader": "^3.4.2", "css-loader": "^3.4.2",
@ -50,8 +52,9 @@
"stylelint-scss": "^4.0.0", "stylelint-scss": "^4.0.0",
"stylelint-webpack-plugin": "^3.3.0", "stylelint-webpack-plugin": "^3.3.0",
"url-loader": "^4.0.0", "url-loader": "^4.0.0",
"vue-loader": "^15.9.1", "vue-loader": "^15",
"vue-template-compiler": "^2.6.11", "vue-style-loader": "^4.1.3",
"vue-template-compiler": "^2.7.13",
"webpack": "^5.0.0", "webpack": "^5.0.0",
"webpack-cli": "^4.0.0", "webpack-cli": "^4.0.0",
"webpack-merge": "^4.2.2", "webpack-merge": "^4.2.2",

View file

@ -22,24 +22,24 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</li> </li>
</ul> </ul>
<Actions> <NcActions>
<ActionButton @click="showAddForm" icon="icon-add"></ActionButton> <NcActionButton @click="showAddForm" icon="icon-add"></NcActionButton>
</Actions> </NcActions>
<Modal v-if="addForm" @close="hideAddForm"> <NcModal v-if="addForm" @close="hideAddForm">
<div class="modal__content"> <div class="modal__content">
<div v-for="lang in langs"> <div v-for="lang in langs">
<span class="lang" v-text="lang"></span> <span class="lang" v-text="lang"></span>
<input type="text" v-model="newValue[lang]" required> <input type="text" v-model="newValue[lang]" required>
</div> </div>
<Actions> <NcActions>
<ActionButton @click="saveAdd" icon="icon-checkmark"></ActionButton> <NcActionButton @click="saveAdd" icon="icon-checkmark"></NcActionButton>
</Actions> </NcActions>
</div> </div>
</Modal> </NcModal>
<Modal v-if="editForm" @close="hideEditForm"> <NcModal v-if="editForm" @close="hideEditForm">
<div class="modal__content"> <div class="modal__content">
<div v-for="lang in langs"> <div v-for="lang in langs">
<span class="lang" v-text="lang"></span> <span class="lang" v-text="lang"></span>
@ -47,16 +47,16 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</div> </div>
<div class="pull-right"> <div class="pull-right">
<Actions> <NcActions>
<ActionButton @click="removeEdit" icon="icon-delete"></ActionButton> <NcActionButton @click="removeEdit" icon="icon-delete"></NcActionButton>
</Actions> </NcActions>
</div> </div>
<Actions> <NcActions>
<ActionButton @click="saveEdit" icon="icon-checkmark"></ActionButton> <NcActionButton @click="saveEdit" icon="icon-checkmark"></NcActionButton>
</Actions> </NcActions>
</div> </div>
</Modal> </NcModal>
</div> </div>
</template> </template>
@ -85,16 +85,16 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
</style> </style>
<script> <script>
import Modal from '@nextcloud/vue/dist/Components/Modal' import NcModal from '@nextcloud/vue/dist/Components/NcModal'
import Actions from '@nextcloud/vue/dist/Components/Actions' import NcActions from '@nextcloud/vue/dist/Components/NcActions'
import ActionButton from '@nextcloud/vue/dist/Components/ActionButton' import NcActionButton from '@nextcloud/vue/dist/Components/NcActionButton'
export default { export default {
name: 'AdminCategoriesCustom', name: 'AdminCategoriesCustom',
components: { components: {
Modal, NcModal,
Actions, NcActions,
ActionButton, NcActionButton,
}, },
data() { data() {
return { return {

296
src/AppMenu.vue Normal file
View file

@ -0,0 +1,296 @@
<!--
- @copyright Copyright (c) 2022 Julius Härtl <jus@bitgrid.net>
-
- @author Julius Härtl <jus@bitgrid.net>
-
- @license GNU AGPL version 3 or any later version
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-->
<template>
<nav class="app-menu show">
<ul class="app-menu-main" v-if="apps !== null">
<li v-for="app in mainAppList()"
:key="app.id"
:data-app-id="app.id"
class="app-menu-entry"
:class="{ 'app-menu-entry__active': app.active }">
<a :href="app.href"
:class="{ 'has-unread': app.unread > 0 }"
:aria-label="appLabel(app)"
:aria-current="app.active ? 'page' : false">
<img :src="app.icon" alt="">
<div class="app-menu-entry--label">
{{ app.name }}
<span v-if="app.unread > 0" class="hidden-visually unread-counter">{{ app.unread }}</span>
</div>
</a>
</li>
</ul>
<NcActions class="app-menu-more" :aria-label="t('core', 'More apps')" v-if="apps !== null">
<NcActionLink v-for="app in popoverAppList()"
:key="app.id"
:aria-label="appLabel(app)"
:aria-current="app.active ? 'page' : false"
:href="app.href"
class="app-menu-popover-entry">
<template #icon>
<div class="app-icon" :class="{ 'has-unread': app.unread > 0 }">
<img :src="app.icon" alt="">
</div>
</template>
{{ app.name }}
<span v-if="app.unread > 0" class="hidden-visually unread-counter">{{ app.unread }}</span>
</NcActionLink>
</NcActions>
</nav>
</template>
<script>
import { loadState } from '@nextcloud/initial-state'
import { NcActions, NcActionLink } from '@nextcloud/vue'
export default {
name: 'AppMenu',
components: {
NcActions, NcActionLink,
},
data() {
return {
apps: null,
appLimit: 0,
observer: null,
}
},
mounted() {
const ncApps = loadState('core', 'apps', {})
this.apps = {}
Array.from(window.topMenuApps).forEach((id) => {
if (ncApps.hasOwnProperty(id)) {
this.apps[id] = ncApps[id]
}
})
this.observer = new ResizeObserver(this.resize)
this.observer.observe(this.$el)
this.resize()
},
beforeDestroy() {
this.observer.disconnect()
},
methods: {
appLabel() {
return (app) => app.name
+ (app.active ? ' (' + t('core', 'Currently open') + ')' : '')
+ (app.unread > 0 ? ' (' + n('core', '{count} notification', '{count} notifications', app.unread, { count: app.unread }) + ')' : '')
},
appList() {
return Object.values(this.apps)
},
mainAppList() {
return this.appList().slice(0, this.appLimit)
},
popoverAppList() {
return this.appList().slice(this.appLimit)
},
setNavigationCounter(id, counter) {
this.$set(this.apps[id], 'unread', counter)
},
resize() {
const availableWidth = this.$el.offsetWidth
let appCount = Math.floor(availableWidth / 50) - 1
const popoverAppCount = this.appList.length - appCount
if (popoverAppCount === 1) {
appCount--
}
if (appCount < 1) {
appCount = 0
}
this.appLimit = appCount
},
},
}
</script>
<style lang="scss" scoped>
$header-icon-size: 20px;
.app-menu {
width: 100%;
display: flex;
flex-shrink: 1;
flex-wrap: wrap;
}
.app-menu-main {
display: flex;
flex-wrap: nowrap;
.app-menu-entry {
width: 50px;
height: 50px;
position: relative;
display: flex;
opacity: .7;
&.app-menu-entry__active {
opacity: 1;
&::before {
content: " ";
position: absolute;
pointer-events: none;
border-bottom-color: var(--color-main-background);
transform: translateX(-50%);
width: 12px;
height: 5px;
border-radius: 3px;
background-color: var(--color-primary-text);
left: 50%;
bottom: 6px;
display: block;
transition: all 0.1s ease-in-out;
opacity: 1;
}
.app-menu-entry--label {
font-weight: bold;
}
}
a {
width: calc(100% - 4px);
height: calc(100% - 4px);
margin: 2px;
color: var(--color-primary-text);
position: relative;
}
img {
transition: margin 0.1s ease-in-out;
width: $header-icon-size;
height: $header-icon-size;
padding: calc((100% - $header-icon-size) / 2);
filter: var(--primary-invert-if-bright);
}
.app-menu-entry--label {
opacity: 0;
position: absolute;
font-size: 12px;
color: var(--color-primary-text);
text-align: center;
bottom: -5px;
left: 50%;
display: block;
min-width: 100%;
transform: translateX(-50%);
transition: all 0.1s ease-in-out;
width: 100%;
text-overflow: ellipsis;
overflow: hidden;
}
&:hover,
&:focus-within {
opacity: 1;
.app-menu-entry--label {
opacity: 1;
font-weight: bold;
font-size: 14px;
bottom: 0;
width: auto;
overflow: visible;
}
}
}
// Show labels
&:hover,
&:focus-within,
.app-menu-entry:hover,
.app-menu-entry:focus {
opacity: 1;
img {
margin-top: -6px;
}
.app-menu-entry--label {
opacity: 1;
bottom: 0;
}
&::before, .app-menu-entry::before {
opacity: 0;
}
}
}
::v-deep .app-menu-more .button-vue--vue-tertiary {
color: var(--color-primary-text);
opacity: .7;
margin: 3px;
&:hover {
opacity: 1;
background-color: transparent !important;
}
&:focus-visible {
opacity: 1;
background-color: transparent !important;
border-radius: var(--border-radius);
outline: none;
box-shadow: 0 0 0 2px var(--color-primary-text);
}
}
.app-menu-popover-entry {
.app-icon {
position: relative;
height: 44px;
&.has-unread::after {
background-color: var(--color-main-text);
}
img {
filter: var(--background-invert-if-bright);
width: $header-icon-size;
height: $header-icon-size;
padding: calc((50px - $header-icon-size) / 2);
}
}
}
.has-unread::after {
content: "";
width: 8px;
height: 8px;
background-color: var(--color-primary-text);
border-radius: 50%;
position: absolute;
display: block;
top: 10px;
right: 10px;
}
.unread-counter {
display: none;
}
</style>

20
src/PageLoader.js Normal file
View file

@ -0,0 +1,20 @@
const createElement = require('./lib/createElement')
const PageLoader = () => {
const pageLoader = createElement('div', {id: 'side-menu-loader'})
const pageLoaderBar = createElement('div', {id: 'side-menu-loader-bar'})
pageLoader.appendChild(pageLoaderBar)
document.querySelector('body').appendChild(pageLoader)
let pageLoaderValue = 0
window.addEventListener('beforeunload', () => {
setInterval(() => {
pageLoaderBar.style.width = pageLoaderValue.toString() + '%'
pageLoaderValue = Math.min(pageLoaderValue + .2, 100)
}, 25)
})
}
module.exports = PageLoader

View file

@ -20,7 +20,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
{{ label }} {{ label }}
<span class="avatardiv avatardiv-shown"> <span class="avatardiv avatardiv-shown">
<img v-bind:src="avatar" v-bind:alt="name" v-bind:title="name"> <img v-bind:src="avatar">
</span> </span>
</a> </a>
</div> </div>

View file

@ -16,35 +16,56 @@
*/ */
import Vue from 'vue' import Vue from 'vue'
import AppMenu from './AppMenu.vue'
import SideMenu from './SideMenu.vue' import SideMenu from './SideMenu.vue'
import SideMenuBig from './SideMenuBig.vue' import SideMenuBig from './SideMenuBig.vue'
import SideMenuWithCategories from './SideMenuWithCategories.vue' import SideMenuWithCategories from './SideMenuWithCategories.vue'
import PageLoader from './PageLoader'
import SMcreateElement from './lib/createElement'
Vue.prototype.OC = OC Vue.prototype.OC = OC
Vue.prototype.t = OC.L10N.translate
window.SMcreateElement = SMcreateElement
window.PageLoader = PageLoader
const mountSideMenuComponent = () => { const mountSideMenuComponent = () => {
const sideMenuContainer = document.querySelector('#side-menu') const container = document.querySelector('#side-menu')
if (sideMenuContainer) { if (!container) {
let component return window.setTimeout(mountSideMenuComponent, 50)
if (sideMenuContainer.getAttribute('data-bigmenu')) {
component = SideMenuBig
} else if(sideMenuContainer.getAttribute('data-sidewithcategories')) {
component = SideMenuWithCategories
} else {
component = SideMenu
} }
const component = (() => {
if (container.getAttribute('data-bigmenu')) {
return SideMenuBig
} else if(container.getAttribute('data-sidewithcategories')) {
return SideMenuWithCategories
} else {
return SideMenu
}
})()
const View = Vue.extend(component) const View = Vue.extend(component)
const sideMenu = new View({}) const App = new View({})
sideMenu.$mount('#side-menu') App.$mount('#side-menu')
document.querySelector('body').dispatchEvent(new CustomEvent('side-menu.ready')) document.querySelector('body').dispatchEvent(new CustomEvent('side-menu.ready'))
} else {
window.setTimeout(mountSideMenuComponent, 50)
} }
const mountAppMenu = () => {
const container = document.querySelector('#header .app-menu')
if (!container) {
return window.setTimeout(mountAppMenu, 50)
}
const View = Vue.extend(AppMenu)
const App = new View({})
App.$mount('#header .app-menu')
} }
mountSideMenuComponent() mountSideMenuComponent()
mountAppMenu()

View file

@ -40,8 +40,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<ul class="side-menu-apps-list"> <ul class="side-menu-apps-list">
<SideMenuApp <SideMenuApp
v-for="app in apps" v-for="(app, key) in apps"
v-bind:classes="{'side-menu-app': true, 'active': app.active}" v-bind:classes="{'side-menu-app': true, 'active': app.active}"
v-bind:key="key"
v-bind:icon="app.icon" v-bind:icon="app.icon"
v-bind:label="app.name" v-bind:label="app.name"
v-bind:href="app.href" v-bind:href="app.href"
@ -58,6 +59,7 @@ import OpenerButton from './OpenerButton'
import SettingsButton from './SettingsButton' import SettingsButton from './SettingsButton'
import SideMenuApp from './SideMenuApp' import SideMenuApp from './SideMenuApp'
import Logo from './Logo' import Logo from './Logo'
import { loadState } from '@nextcloud/initial-state'
export default { export default {
name: 'SideMenu', name: 'SideMenu',
@ -80,89 +82,31 @@ export default {
}, },
methods: { methods: {
retrieveApps() { retrieveApps() {
this.apps = [] this.apps = loadState('core', 'apps', {})
const links = document.querySelectorAll('#appmenu a')
const menu = document.querySelector('#appmenu')
let menuIsHidden = true
if (menu) {
menuIsHidden = window.getComputedStyle(menu, null).getPropertyValue('display') === 'none'
}
for (let element of links) {
let href = element.getAttribute('href')
let parent = element.parentNode
if (!parent) {
continue
}
let dataId = parent.getAttribute('data-id')
dataId = dataId !== null ? dataId : ''
if (!parent.classList.contains('app-top-side-menu') && !parent.classList.contains('app-hidden') && !menuIsHidden) {
continue
}
if (href !== '#') {
let svg = element.querySelector('svg').outerHTML
svg = svg
.replace(/(height|width)="20"/, '')
.replace('id="invertMenuMain', 'id="invertSideMenu')
.replace('url(#invertMenuMain', 'url(#invertSideMenu')
if (this.forceLightIcon) {
svg = svg.replace(/filter="url[^"]+"/, '')
}
this.apps.push({
id: dataId,
href: href,
name: trim(element.querySelector('span').innerHTML),
icon: svg,
active: element.classList.contains('active')
})
}
}
(function(apps) {
window.setTimeout(function() {
document.querySelector('body').dispatchEvent(new CustomEvent('side-menu.apps', { document.querySelector('body').dispatchEvent(new CustomEvent('side-menu.apps', {
detail: {apps: apps}, detail: {apps: this.apps},
})) }))
}, 1000)
})(this.apps)
}, },
retrieveConfig() { retrieveConfig() {
let that = this
axios axios
.get(OC.generateUrl('/apps/side_menu/js/config')) .get(OC.generateUrl('/apps/side_menu/js/config'))
.then(function(response) { .then((response) => {
const config = response.data const config = response.data
that.targetBlankApps = config['target-blank-apps'] this.targetBlankApps = config['target-blank-apps']
that.forceLightIcon = config['force-light-icon'] this.forceLightIcon = config['force-light-icon']
that.avatar = config['avatar'] this.avatar = config['avatar']
that.logo = config['logo'] this.logo = config['logo']
that.logoLink = config['logo-link'] this.logoLink = config['logo-link']
that.settings = config['settings'] this.settings = config['settings']
}) })
}, },
}, },
mounted() { mounted() {
this.retrieveConfig() this.retrieveConfig()
this.retrieveApps() this.retrieveApps()
const menu = document.querySelector('#appmenu')
if (menu) {
const config = {attributes: true, childList: true, subtree: true}
const observer = new MutationObserver(this.retrieveApps)
observer.observe(menu, config)
}
} }
} }
</script> </script>

View file

@ -17,7 +17,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<template> <template>
<li v-bind:class="classes"> <li v-bind:class="classes">
<a v-bind:href="href" :target="target" v-bind:title="label"> <a v-bind:href="href" :target="target" v-bind:title="label">
<span class="side-menu-app-icon" v-html="icon"></span> <img class="side-menu-app-icon" v-bind:src="icon" v-bind:alt="label" />
<span class="side-menu-app-text" v-text="label"></span> <span class="side-menu-app-text" v-text="label"></span>
</a> </a>
</li> </li>

View file

@ -33,12 +33,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div class="side-menu-categories"> <div class="side-menu-categories">
<Loader v-if="!items.length" /> <Loader v-if="!items.length" />
<div class="side-menu-category" v-for="category in items"> <div class="side-menu-category" v-for="(category, key) in items" v-bind:key="key">
<h2 class="side-menu-category-title" v-if="category.name != ''" v-text="category.name"></h2> <h2 class="side-menu-category-title" v-if="category.name != ''" v-text="category.name"></h2>
<ul class="side-menu-apps-list"> <ul class="side-menu-apps-list">
<SideMenuBigApp <SideMenuBigApp
v-for="(app, appId) in category.apps" v-for="(app, appId) in category.apps"
v-bind:key="appId"
v-bind:classes="{'side-menu-app': true, 'active': activeApp === appId}" v-bind:classes="{'side-menu-app': true, 'active': activeApp === appId}"
v-bind:icon="app.icon" v-bind:icon="app.icon"
v-bind:label="app.name" v-bind:label="app.name"

View file

@ -31,12 +31,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<div class="side-menu-categories"> <div class="side-menu-categories">
<Loader v-if="!items.length" /> <Loader v-if="!items.length" />
<div class="side-menu-category" v-for="category in items"> <div class="side-menu-category" v-for="(category, key) in items" v-bind:key="key">
<h2 class="side-menu-category-title" v-if="category.name != ''" v-text="category.name"></h2> <h2 class="side-menu-category-title" v-if="category.name != ''" v-text="category.name"></h2>
<ul class="side-menu-apps-list"> <ul class="side-menu-apps-list">
<SideMenuBigApp <SideMenuBigApp
v-for="(app, appId) in category.apps" v-for="(app, appId) in category.apps"
v-bind:key="appId"
v-bind:classes="{'side-menu-app': true, 'active': activeApp === appId}" v-bind:classes="{'side-menu-app': true, 'active': activeApp === appId}"
v-bind:icon="app.icon" v-bind:icon="app.icon"
v-bind:label="app.name" v-bind:label="app.name"

11
src/lib/createElement.js Normal file
View file

@ -0,0 +1,11 @@
module.exports = (tagName, attributes) => {
const element = document.createElement(tagName)
if (typeof attributes === 'object') {
for (let i in attributes) {
element.setAttribute(i, attributes[i])
}
}
return element
}

View file

@ -114,3 +114,15 @@
font-size: 16px; font-size: 16px;
} }
<?php endif; ?> <?php endif; ?>
<?php if ($_['always-displayed']): ?>
#content {
left: 53px;
width: calc(100% - (var(--body-container-margin) * 2) - 62px);
}
#content-vue {
width: calc(100% - (var(--body-container-margin) * 2) - 60px);
margin-left: 11px;
}
<?php endif; ?>

View file

@ -1,86 +0,0 @@
const alwaysDisplayed = function() {
const elements = querySelectorAll('*')
const fixedElements = []
for (let element of elements) {
if (typeof element !== 'object') {
continue
}
const position = window.getComputedStyle(element, null).getPropertyValue('position')
if (position !== 'fixed') {
continue
}
const id = element.getAttribute('id')
if (id === 'header' || id === 'side-menu' || id === 'side-menu-loader') {
continue
}
if (element.classList.contains('oc-dialog')) {
continue
}
let elementIsInSideMenu = false
let parent = element.parentNode
while (parent && !elementIsInSideMenu) {
try {
if (parent.getAttribute('id') === 'side-menu') {
elementIsInSideMenu = true
}
} catch (e) {
}
parent = parent.parentNode
}
if (elementIsInSideMenu) {
continue
}
fixedElements.push(element)
}
for (let i in fixedElements) {
const element = fixedElements[i]
const computedStyle = window.getComputedStyle(element, null)
const left = computedStyle.getPropertyValue('left')
const right = computedStyle.getPropertyValue('right')
if (right !== '0px') {
const intValue = parseInt(left.replace('px', '')) + 50
element.style.setProperty('transform', 'translateX(' + intValue.toString() + 'px)')
}
}
}
const content = querySelector('#content')
if (content && content.classList.contains('app-settings')) {
let loaded = false
const config = {
attributes: false,
childList: true,
subtree: true
}
const observer = new MutationObserver(() => {
if (loaded) {
return
}
const element = content.querySelector('#app-category-your-apps') || content.querySelector('#app-navigation ul')
if (element) {
loaded = true
alwaysDisplayed()
}
})
observer.observe(content, config)
} else {
window.setTimeout(alwaysDisplayed, 200)
}

View file

@ -1,14 +0,0 @@
let pageLoader = createElement('div', {id: 'side-menu-loader'})
let pageLoaderBar = createElement('div', {id: 'side-menu-loader-bar'})
pageLoader.appendChild(pageLoaderBar)
querySelector('body').appendChild(pageLoader)
let pageLoaderValue = 0
window.addEventListener('beforeunload', () => {
setInterval(() => {
pageLoaderBar.style.width = pageLoaderValue.toString() + '%'
pageLoaderValue = Math.min(pageLoaderValue + .2, 100)
}, 25)
})

View file

@ -1,216 +0,0 @@
let menuCache = null
const breakpointMobileWidth = 1024
const usePercentualAppMenuLimit = 0.8
const minAppsDesktop = 8
const handleMenuClick = (e, icon) => {
let element = e.target
while (element.tagName !== 'LI') {
element = element.parentNode
}
const a = querySelector('a', element)
if (a.getAttribute('target') !== '_blank' && e.which === 1 && !e.ctrlKey && !e.metaKey) {
for (let tag of ['svg', 'div']) {
let el = querySelector(tag, element)
if (el) {
el.remove()
}
}
const loader = createElement('div', {'class': icon})
a.insertBefore(loader, querySelector('span', a))
}
}
const updateTopMenu = function() {
const isMobile = window.innerWidth < breakpointMobileWidth
const menu = querySelector('#appmenu')
const moreApps = querySelector('#more-apps')
const navigation = querySelector('#navigation')
const navigationApps = querySelector('#apps ul')
let apps = querySelectorAll('li', menu)
let lastShownApp = null
let appShown = []
if ((menu.innerHTML + menu.nextSibling.innerHTML) === menuCache) {
return
}
let navigationAppsHtml = ''
for (let app of apps) {
const dataId = app.getAttribute('data-id')
if (dataId === null) {
continue
}
if (topMenuApps.indexOf(dataId) === -1 && topSideMenuApps.indexOf(dataId) === -1) {
app.classList.add('hidden')
app.classList.add('app-hidden')
} else {
app.classList.remove('hidden')
app.classList.add('app-external-site')
if (topSideMenuApps.indexOf(dataId) !== -1) {
app.classList.add('app-top-side-menu')
}
appShown.push(app)
navigationAppsHtml = navigationAppsHtml + app.outerHTML
}
if (targetBlankApps.indexOf(dataId) !== -1) {
querySelector('a', app).setAttribute('target', '_blank')
}
}
navigationApps.innerHTML = navigationAppsHtml
const rightHeaderWidth = querySelector('.header-right').offsetWidth
const headerWidth = querySelector('header').offsetWidth
let availableWidth = headerWidth
availableWidth -= nextcloud.offsetWidth
availableWidth -= querySelector('#header .side-menu-opener').offsetWidth
availableWidth -= rightHeaderWidth > 230 ? rightHeaderWidth : 230
availableWidth *= isMobile ? usePercentualAppMenuLimit : 1
let appCount = Math.floor(availableWidth / querySelector('#appmenu li:not(.hidden)').offsetWidth)
if (isMobile && appCount > minAppsDesktop) {
appCount = minAppsDesktop
} else if (!isMobile && appCount < minAppsDesktop) {
appCount = minAppsDesktop
}
menu.style.opacity = 1
if (appShown.length - 1 - appCount >= 1) {
appCount--
}
for (let item of querySelectorAll('a', moreApps)) {
item.classList.remove('active')
}
let k = 0
let notInHeader = 0
for (let app of appShown) {
const name = app.getAttribute('data-id')
const li = querySelector('#apps li[data-id=' + name + '].app-external-site')
if (k < appCount && appCount > 0) {
app.classList.remove('hidden')
li.classList.add('in-header')
lastShownApp = app
} else {
app.classList.add('hidden')
li.classList.remove('in-header')
notInHeader++
const a = querySelector('a', app)
if (appCount > 0 && a.classList.contains('active')) {
lastShownApp.classList.add('hidden')
app.classList.remove('hidden')
notInHeader++
li.classList.add('in-header')
}
}
k++
}
// Hack for:
// - https://github.com/nextcloud/server/blob/master/core/src/components/MainMenu.js#L97-L119
// - https://github.com/nextcloud/server/blob/master/core/src/components/MainMenu.js#L97-L119
jQuery(menu).undelegate('li:not(#more-apps) > a', 'click')
jQuery(navigation).undelegate('a', 'click')
const confs = [
{
items: querySelectorAll('#navigation li'),
icon: 'icon-loading-small'
},
{
items: querySelectorAll('li:not(#more-apps)', menu),
icon: OCA.Theming && OCA.Theming.inverted ? 'icon-loading-small' : 'icon-loading-small-dark'
},
]
for (let conf of confs) {
for (let item of conf.items) {
item.addEventListener('click', (e) => {
handleMenuClick(e, conf.icon)
})
}
}
for (let app of querySelectorAll('#apps li.app-external-site')) {
const appId = app.getAttribute('data-id')
if (app.classList.contains('in-header')) {
for (let defs of querySelectorAll('svg defs', app)) {
defs.remove()
}
} else {
const svg = querySelector('svg', app)
if (querySelectorAll('svg defs', app).length > 0) {
continue
}
const defs = `
<defs>
<filter id="invertMenuMore-${appId}">
<feColorMatrix in="SourceGraphic" type="matrix" values="-1 0 0 0 1 0 -1 0 0 1 0 0 -1 0 1 0 0 0 1 0"></feColorMatrix>
</filter>
</defs>`
svg.innerHTML = defs + svg.innerHTML
for (let image of querySelectorAll('image', svg)) {
image.setAttribute('filter', `url(#invertMenuMore-${appId})`)
}
svg.innerHTML = svg.innerHTML.replace(/fecolormatrix/g, 'feColorMatrix')
}
}
if (notInHeader === 0) {
moreApps.style.display = 'none'
navigation.style.display = 'none'
} else {
moreApps.style.display = 'flex'
}
menuCache = menu.innerHTML + menu.nextSibling.innerHTML
}
for (let i = 0; i < 4000; i+= 100) {
setTimeout(updateTopMenu, i)
}
let resizeTimeout = null;
window.addEventListener('resize', () => {
if (resizeTimeout !== null) {
clearTimeout(resizeTimeout)
}
resizeTimeout = setTimeout(updateTopMenu, 100)
})

View file

@ -13,56 +13,31 @@ if ($_['always-displayed']) {
?> ?>
(function() { (function() {
const querySelector = function(selector, element) { const sideMenuContainer = SMcreateElement('div', {id: 'side-menu-container'})
if (element) { const sideMenuOpener = SMcreateElement('button', {'class': 'side-menu-opener'})
return element.querySelector(selector) const sideMenu = SMcreateElement('div', {id: 'side-menu'})
}
return document.querySelector(selector) const body = document.querySelector('body')
} const html = document.querySelector('html')
const nextcloud = document.querySelector('#nextcloud')
const querySelectorAll = function(selector, element) {
if (element) {
return element.querySelectorAll(selector)
}
return document.querySelectorAll(selector)
}
const createElement = function(tagName, attributes) {
const element = document.createElement(tagName)
if (typeof attributes === 'object') {
for (let i in attributes) {
element.setAttribute(i, attributes[i])
}
}
return element
}
const sideMenuContainer = createElement('div', {id: 'side-menu-container'})
const sideMenuOpener = createElement('button', {'class': 'side-menu-opener'})
const sideMenu = createElement('div', {id: 'side-menu'})
const body = querySelector('body')
const html = querySelector('html')
const nextcloud = querySelector('#nextcloud')
const isTouchDevice = window.matchMedia("(pointer: coarse)").matches const isTouchDevice = window.matchMedia("(pointer: coarse)").matches
const targetBlankApps = <?php echo json_encode($_['target-blank-apps']) ?> const targetBlankApps = <?php echo json_encode($_['target-blank-apps']) ?>
window.topMenuApps = <?php echo json_encode($_['top-menu-apps']), "\n"; ?>
window.topSideMenuApps = <?php echo json_encode($_['top-side-menu-apps']); ?>
<?php if ($display === 'big-menu'): ?> <?php if ($display === 'big-menu'): ?>
sideMenu.setAttribute('data-bigmenu', '1') sideMenu.setAttribute('data-bigmenu', '1')
<?php elseif ($display === 'side-with-categories'): ?> <?php elseif ($display === 'side-with-categories'): ?>
sideMenu.setAttribute('data-sidewithcategories', '1') sideMenu.setAttribute('data-sidewithcategories', '1')
<?php endif; ?> <?php endif; ?>
querySelector('body').addEventListener('side-menu.apps', (e) => { document.querySelector('body').addEventListener('side-menu.apps', (e) => {
const apps = e.detail.apps; const apps = e.detail.apps;
<?php if ($_['hide-when-no-apps']): ?> <?php if ($_['hide-when-no-apps']): ?>
const sideMenu = querySelector('#side-menu') const sideMenu = document.querySelector('#side-menu')
if (apps.length === 0) { if (apps.length === 0) {
sideMenu.classList.remove('open') sideMenu.classList.remove('open')
@ -92,19 +67,19 @@ if ($_['always-displayed']) {
}) })
body.addEventListener('side-menu.ready', () => { body.addEventListener('side-menu.ready', () => {
const sideMenu = querySelector('#side-menu') const sideMenu = document.querySelector('#side-menu')
const headerMenuOpener = querySelector('#header .side-menu-opener') const headerMenuOpener = document.querySelector('#header .side-menu-opener')
const sideMenuOpener = querySelectorAll('#side-menu .side-menu-opener') const sideMenuOpener = document.querySelectorAll('#side-menu .side-menu-opener')
sideMenuFocus = () => { sideMenuFocus = () => {
let a = querySelector('.side-menu-app.active a', sideMenu) let a = document.querySelector('.side-menu-app.active a', sideMenu)
if (!a) { if (!a) {
return return
} }
if (a.length === 0) { if (a.length === 0) {
a = querySelector('.side-menu-app:first-child a', sideMenu) a = sideMenu.querySelector('.side-menu-app:first-child a')
} }
if (a.length > 0) { if (a.length > 0) {
@ -144,7 +119,7 @@ if ($_['always-displayed']) {
headerMenuOpener.addEventListener('click', () => { headerMenuOpener.addEventListener('click', () => {
sideMenu.classList.add('open') sideMenu.classList.add('open')
const a = querySelector('.side-menu-app.active a', sideMenu) const a = sideMenu.querySelector('.side-menu-app.active a')
if (a !== null) { if (a !== null) {
a.focus() a.focus()
@ -194,7 +169,7 @@ if ($_['always-displayed']) {
sideMenuContainer.appendChild(sideMenu) sideMenuContainer.appendChild(sideMenu)
<?php if ($_['loader-enabled'] === true): ?> <?php if ($_['loader-enabled'] === true): ?>
<?php require_once __DIR__.'/_loaderEnabled.js'; ?> PageLoader()
<?php endif; ?> <?php endif; ?>
<?php if ($_['opener-position'] === 'before'): ?> <?php if ($_['opener-position'] === 'before'): ?>
@ -202,15 +177,4 @@ if ($_['always-displayed']) {
<?php else: ?> <?php else: ?>
nextcloud.parentNode.insertBefore(sideMenuOpener, nextcloud.nextSibling) nextcloud.parentNode.insertBefore(sideMenuOpener, nextcloud.nextSibling)
<?php endif; ?> <?php endif; ?>
<?php if (!empty($_['top-menu-apps']) || !empty($_['top-side-menu-apps'])): ?>
const topMenuApps = <?php echo json_encode($_['top-menu-apps']), "\n"; ?>
const topSideMenuApps = <?php echo json_encode($_['top-side-menu-apps']); ?>
<?php require_once __DIR__.'/_topMenuApps.js'; ?>
<?php endif; ?>
<?php if ($display === 'always-displayed'): ?>
<?php require_once __DIR__.'/_alwaysDisplayed.js'; ?>
<?php endif; ?>
})(); })();

View file

@ -3,6 +3,7 @@ const { VueLoaderPlugin } = require('vue-loader')
const StyleLintPlugin = require('stylelint-webpack-plugin') const StyleLintPlugin = require('stylelint-webpack-plugin')
module.exports = { module.exports = {
devtool: "source-map",
entry: { entry: {
'admin': path.join(__dirname, 'src', 'admin.js'), 'admin': path.join(__dirname, 'src', 'admin.js'),
'sideMenu': path.join(__dirname, 'src', 'SideMenu.js'), 'sideMenu': path.join(__dirname, 'src', 'SideMenu.js'),