Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
1 change: 1 addition & 0 deletions apps/dashboard/lib/Controller/DashboardController.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ public function __construct(
#[NoAdminRequired]
#[FrontpageRoute(verb: 'GET', url: '/')]
public function index(): TemplateResponse {
Util::addStyle('dashboard', 'main');
Util::addStyle('dashboard', 'dashboard');
Util::addScript('dashboard', 'main', 'theming');

Expand Down
233 changes: 143 additions & 90 deletions apps/dashboard/src/DashboardApp.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,45 +10,45 @@
v-for="status in sortedRegisteredStatus"
:id="'status-' + status"
:key="status">
<div :ref="'status-' + status" />
<div :ref="(element) => setStatusElement(status, element)" />
</li>
</ul>

<Draggable
v-model="layout"
class="panels"
v-bind="{ swapThreshold: 0.30, delay: 500, delayOnTouchOnly: true, touchStartThreshold: 3 }"
class="panels"
:itemKey="(element) => element"
handle=".panel--header"
@end="saveLayout">
<template v-for="panelId in layout">
<div
v-if="isApiWidgetV2(panels[panelId].id)"
:key="`${panels[panelId].id}-v2`"
class="panel">
<div class="panel--header">
<h2>
<img v-if="apiWidgets[panels[panelId].id].icon_url" :src="apiWidgets[panels[panelId].id].icon_url" alt="">
<span v-else :class="apiWidgets[panels[panelId].id].icon_class" aria-hidden="true" />
{{ apiWidgets[panels[panelId].id].title }}
</h2>
</div>
<div class="panel--content">
<ApiDashboardWidget
:widget="apiWidgets[panels[panelId].id]"
:data="apiWidgetItems[panels[panelId].id]"
:loading="loadingItems" />
</div>
</div>
<div v-else :key="panels[panelId].id" class="panel">
<div class="panel--header">
<h2>
<span :class="panels[panelId].iconClass" aria-hidden="true" />
{{ panels[panelId].title }}
</h2>
</div>
<div class="panel--content" :class="{ loading: !panels[panelId].mounted }">
<div :ref="panels[panelId].id" :data-id="panels[panelId].id" />
</div>
<template #item="{ element: panelId }">
<div :key="panelId" class="panel">
<template v-if="isApiWidgetV2(panels[panelId].id)">
<div class="panel--header">
<h2>
<img v-if="apiWidgets[panels[panelId].id].icon_url" :src="apiWidgets[panels[panelId].id].icon_url" alt="">
<span v-else :class="apiWidgets[panels[panelId].id].icon_class" aria-hidden="true" />
{{ apiWidgets[panels[panelId].id].title }}
</h2>
</div>
<div class="panel--content">
<ApiDashboardWidget
:widget="apiWidgets[panels[panelId].id]"
:data="apiWidgetItems[panels[panelId].id]"
:loading="loadingItems" />
</div>
</template>
<template v-else>
<div class="panel--header">
<h2>
<span :class="panels[panelId].iconClass" aria-hidden="true" />
{{ panels[panelId].title }}
</h2>
</div>
<div class="panel--content" :class="{ loading: !panels[panelId].mounted }">
<div :ref="panels[panelId].id" :data-id="panels[panelId].id" />
</div>
</template>
</div>
</template>
</Draggable>
Expand All @@ -62,7 +62,11 @@
</NcButton>
</div>

<NcModal v-if="modal" size="large" @close="closeModal">
<NcModal
v-if="modal"
size="large"
:closeOnClickOutside="true"
@close="closeModal">
<div class="modal__content">
<h2>{{ t('dashboard', 'Edit widgets') }}</h2>
<ol class="panels">
Expand All @@ -81,27 +85,31 @@
</li>
</ol>
<Draggable
v-model="layout"
v-bind="{ swapThreshold: 0.30, delay: 500, delayOnTouchOnly: true, touchStartThreshold: 3 }"
class="panels"
tag="ol"
v-bind="{ swapThreshold: 0.30, delay: 500, delayOnTouchOnly: true, touchStartThreshold: 3 }"
:itemKey="(panel) => panel.id"
handle=".draggable"
@end="saveLayout">
<li v-for="panel in sortedPanels" :key="panel.id" :class="'panel-' + panel.id">
<input
:id="'panel-checkbox-' + panel.id"
type="checkbox"
class="checkbox"
:checked="isActive(panel)"
@input="updateCheckbox(panel, $event.target.checked)">
<label :for="'panel-checkbox-' + panel.id" :class="{ draggable: isActive(panel) }">
<img v-if="panel.iconUrl" alt="" :src="panel.iconUrl">
<span v-else :class="panel.iconClass" aria-hidden="true" />
{{ panel.title }}
</label>
</li>
:modelValue="modalPanelList"
@update:modelValue="onModalPanelListUpdate"
@start="onModalDragStart"
@end="onModalDragEnd">
<template #item="{ element: panel }">
<li :key="panel.id" :class="'panel-' + panel.id">
<input
:id="'panel-checkbox-' + panel.id"
type="checkbox"
class="checkbox"
:checked="isActive(panel)"
@input="updateCheckbox(panel, $event.target.checked)">
<label :for="'panel-checkbox-' + panel.id" :class="{ draggable: isActive(panel) }">
<img v-if="panel.iconUrl" alt="" :src="panel.iconUrl">
<span v-else :class="panel.iconClass" aria-hidden="true" />
{{ panel.title }}
</label>
</li>
</template>
</Draggable>

<a v-if="isAdmin && appStoreEnabled" :href="appStoreUrl" class="button">{{ t('dashboard', 'Get more widgets from the App Store') }}</a>

<div v-if="statuses.weather && isStatusActive('weather')">
Expand All @@ -124,9 +132,11 @@
import { getCurrentUser } from '@nextcloud/auth'
import axios from '@nextcloud/axios'
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import { generateOcsUrl, generateUrl } from '@nextcloud/router'
import Vue from 'vue'
import Draggable from 'vuedraggable'
// import this directly so Vite builds it and we don't end up using the UMD bundle which
// tries to run "new Function(...)" (for no good reason) which is blocked by our CSP
import Draggable from 'vuedraggable/src/vuedraggable.js'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcModal from '@nextcloud/vue/components/NcModal'
import NcUserStatusIcon from '@nextcloud/vue/components/NcUserStatusIcon'
Expand Down Expand Up @@ -166,6 +176,7 @@ export default {

setup() {
return {
t,
productName: window.OC.theme.productName,
}
},
Expand All @@ -176,15 +187,17 @@ export default {
timer: new Date(),
registeredStatus: [],
callbacks: {},
callbacksStatus: {},
allCallbacksStatus: {},
statusElements: {},
statusInfo,
enabledStatuses: loadState('dashboard', 'statuses'),
panels,
firstRun,
displayName: getCurrentUser()?.displayName,
uid: getCurrentUser()?.uid,
layout: loadState('dashboard', 'layout').filter((panelId) => panels[panelId]),
modalPanelList: [],
isModalDragging: false,
modal: false,
appStoreUrl: generateUrl('/settings/apps/dashboard'),
appStoreEnabled: loadState('dashboard', 'appStoreEnabled', true),
Expand Down Expand Up @@ -263,39 +276,19 @@ export default {
return Object.keys(this.allCallbacksStatus).slice().sort(this.sortStatuses)
},

sortedPanels() {
return Object.values(this.panels).sort((a, b) => {
const indexA = this.layout.indexOf(a.id)
const indexB = this.layout.indexOf(b.id)
if (indexA === -1 || indexB === -1) {
return indexB - indexA || a.id - b.id
}
return indexA - indexB || a.id - b.id
})
},

sortedRegisteredStatus() {
return this.registeredStatus.slice().sort(this.sortStatuses)
},
},

watch: {
callbacks() {
this.rerenderPanels()
this.$nextTick(() => this.rerenderPanels())
},

callbacksStatus() {
for (const app in this.callbacksStatus) {
const element = this.$refs['status-' + app]
if (this.statuses[app] && this.statuses[app].mounted) {
continue
}
if (element) {
this.callbacksStatus[app](element[0])
Vue.set(this.statuses, app, { mounted: true })
} else {
logger.error('Failed to register panel in the frontend as no backend data was provided for ' + app)
}
layout() {
if (this.modal && !this.isModalDragging) {
this.modalPanelList = this.getSortedPanelObjects()
}
},
},
Expand All @@ -320,6 +313,8 @@ export default {
}, widget.reload_interval * 1000)
}
}

this.$nextTick(() => this.rerenderPanels())
},

mounted() {
Expand All @@ -335,7 +330,7 @@ export default {
}
},

destroyed() {
unmounted() {
window.removeEventListener('scroll', this.handleScroll)
},

Expand All @@ -347,18 +342,40 @@ export default {
* @param {Function} callback The callback function to register a panel which gets the DOM element passed as parameter
*/
register(app, callback) {
Vue.set(this.callbacks, app, callback)
this.callbacks[app] = callback
},

registerStatus(app, callback) {
// always save callbacks in case user enables the status later
Vue.set(this.allCallbacksStatus, app, callback)
this.allCallbacksStatus[app] = callback
// register only if status is enabled or missing from config
if (this.isStatusActive(app)) {
this.registeredStatus.push(app)
this.$nextTick(() => {
Vue.set(this.callbacksStatus, app, callback)
})
if (!this.registeredStatus.includes(app)) {
this.registeredStatus.push(app)
}
this.$nextTick(() => this.mountStatus(app))
}
},

setStatusElement(app, element) {
if (element) {
this.statusElements[app] = element
this.mountStatus(app)
} else {
delete this.statusElements[app]
}
},

mountStatus(app) {
if (this.statuses[app]?.mounted) {
return
}

const element = this.statusElements[app]
const callback = this.allCallbacksStatus[app]
if (element && callback) {
callback(element)
this.statuses[app] = { mounted: true }
}
},

Expand All @@ -377,10 +394,12 @@ export default {
continue
}
if (element) {
this.callbacks[app](element[0], {
// In Vue 3, refs in v-for are arrays
const el = Array.isArray(element) ? element[0] : element
this.callbacks[app](el, {
widget: this.panels[app],
})
Vue.set(this.panels[app], 'mounted', true)
this.panels[app].mounted = true
} else {
logger.error('Failed to register panel in the frontend as no backend data was provided for ' + app)
}
Expand All @@ -402,12 +421,40 @@ export default {
showModal() {
this.modal = true
this.firstRun = false
this.modalPanelList = this.getSortedPanelObjects()
},

closeModal() {
this.modal = false
},

getSortedPanelObjects() {
return Object.values(this.panels).sort((a, b) => {
const indexA = this.layout.indexOf(a.id)
const indexB = this.layout.indexOf(b.id)
if (indexA === -1 || indexB === -1) {
return indexB - indexA || a.id - b.id
}
return indexA - indexB || a.id - b.id
})
},

onModalPanelListUpdate(newList) {
this.modalPanelList = newList
},

onModalDragStart() {
this.isModalDragging = true
},

onModalDragEnd() {
this.isModalDragging = false
this.layout = this.modalPanelList
.filter((panel) => this.layout.includes(panel.id))
.map((panel) => panel.id)
this.saveLayout()
},

updateCheckbox(panel, currentValue) {
const index = this.layout.indexOf(panel.id)
if (!currentValue && index > -1) {
Expand All @@ -418,7 +465,7 @@ export default {
this.fetchApiWidgetItems([panel.id], true)
}
}
Vue.set(this.panels[panel.id], 'mounted', false)
this.panels[panel.id].mounted = false
this.saveLayout()
this.$nextTick(() => this.rerenderPanels())
},
Expand Down Expand Up @@ -457,10 +504,7 @@ export default {
const j = this.registeredStatus.findIndex((s) => s === app)
if (j !== -1) {
this.registeredStatus.splice(j, 1)
Vue.set(this.statuses, app, { mounted: false })
this.$nextTick(() => {
Vue.delete(this.callbacksStatus, app)
})
this.statuses[app] = { mounted: false }
}
this.saveStatuses()
},
Expand Down Expand Up @@ -517,6 +561,11 @@ export default {
</script>

<style lang="scss" scoped>
:global(#content.app-dashboard) {
margin-inline: 0;
width: 100%;
}

#app-dashboard {
width: 100%;
min-height: 100%;
Expand Down Expand Up @@ -803,4 +852,8 @@ html, body {
/* Scrollbar sits on the background image — use plain-text color for contrast */
scrollbar-color: var(--color-background-plain-text) transparent;
}

#app-content-vue {
width: 100%;
}
</style>
Loading
Loading