aboutsummaryrefslogtreecommitdiffstats
path: root/apps/dashboard/src
diff options
context:
space:
mode:
Diffstat (limited to 'apps/dashboard/src')
-rw-r--r--apps/dashboard/src/DashboardApp.vue (renamed from apps/dashboard/src/App.vue)438
-rw-r--r--apps/dashboard/src/components/ApiDashboardWidget.vue116
-rw-r--r--apps/dashboard/src/components/ApiDashboardWidgetItem.vue68
-rw-r--r--apps/dashboard/src/components/BackgroundSettings.vue191
-rw-r--r--apps/dashboard/src/helpers/getBackgroundUrl.js39
-rw-r--r--apps/dashboard/src/helpers/prefixWithBaseUrl.js24
-rw-r--r--apps/dashboard/src/main.js24
-rw-r--r--apps/dashboard/src/mixins/isMobile.js23
8 files changed, 480 insertions, 443 deletions
diff --git a/apps/dashboard/src/App.vue b/apps/dashboard/src/DashboardApp.vue
index a327bc3467c..afc874be2c9 100644
--- a/apps/dashboard/src/App.vue
+++ b/apps/dashboard/src/DashboardApp.vue
@@ -1,49 +1,76 @@
+<!--
+ - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+ -->
<template>
- <div id="app-dashboard" :style="backgroundStyle">
+ <main id="app-dashboard">
<h2>{{ greeting.text }}</h2>
<ul class="statuses">
- <div v-for="status in sortedRegisteredStatus"
+ <li v-for="status in sortedRegisteredStatus"
:id="'status-' + status"
:key="status">
<div :ref="'status-' + status" />
- </div>
+ </li>
</ul>
<Draggable v-model="layout"
class="panels"
+ v-bind="{swapThreshold: 0.30, delay: 500, delayOnTouchOnly: true, touchStartThreshold: 3}"
handle=".panel--header"
@end="saveLayout">
- <div v-for="panelId in layout" :key="panels[panelId].id" class="panel">
- <div class="panel--header">
- <h2 :class="panels[panelId].iconClass">
- {{ panels[panelId].title }}
- </h2>
+ <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 class="panel--content" :class="{ loading: !panels[panelId].mounted }">
- <div :ref="panels[panelId].id" :data-id="panels[panelId].id" />
+ <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>
</div>
- </div>
+ </template>
</Draggable>
<div class="footer">
- <a class="edit-panels icon-rename"
- tabindex="0"
- @click="showModal"
- @keyup.enter="showModal"
- @keyup.space="showModal">{{ t('dashboard', 'Customize') }}</a>
+ <NcButton @click="showModal">
+ <template #icon>
+ <Pencil :size="20" />
+ </template>
+ {{ t('dashboard', 'Customize') }}
+ </NcButton>
</div>
- <Modal v-if="modal" @close="closeModal">
+ <NcModal v-if="modal" size="large" @close="closeModal">
<div class="modal__content">
- <h3>{{ t('dashboard', 'Edit widgets') }}</h3>
+ <h2>{{ t('dashboard', 'Edit widgets') }}</h2>
<ol class="panels">
- <li v-for="status in sortedAllStatuses" :key="status">
+ <li v-for="status in sortedAllStatuses" :key="status" :class="'panel-' + status">
<input :id="'status-checkbox-' + status"
type="checkbox"
class="checkbox"
:checked="isStatusActive(status)"
@input="updateStatusCheckbox(status, $event.target.checked)">
- <label :for="'status-checkbox-' + status" :class="statusInfo[status].icon">
+ <label :for="'status-checkbox-' + status">
+ <NcUserStatusIcon v-if="status === 'status'" status="online" aria-hidden="true" />
+ <span v-else :class="statusInfo[status].icon" aria-hidden="true" />
{{ statusInfo[status].text }}
</label>
</li>
@@ -51,59 +78,60 @@
<Draggable v-model="layout"
class="panels"
tag="ol"
+ v-bind="{swapThreshold: 0.30, delay: 500, delayOnTouchOnly: true, touchStartThreshold: 3}"
handle=".draggable"
@end="saveLayout">
- <li v-for="panel in sortedPanels" :key="panel.id">
+ <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="isActive(panel) ? 'draggable ' + panel.iconClass : panel.iconClass">
+ <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>
</Draggable>
- <a v-if="isAdmin" :href="appStoreUrl" class="button">{{ t('dashboard', 'Get more widgets from the app store') }}</a>
-
- <h3>{{ t('dashboard', 'Change background image') }}</h3>
- <BackgroundSettings :background="background"
- :theming-default-background="themingDefaultBackground"
- @update:background="updateBackground" />
-
- <h3>{{ t('dashboard', 'Weather service') }}</h3>
- <p>
- {{ t('dashboard', 'For your privacy, the weather data is requested by your Nextcloud server on your behalf so the weather service receives no personal information.') }}
- </p>
- <p class="credits--end">
- <a href="https://api.met.no/doc/TermsOfService" target="_blank" rel="noopener">{{ t('dashboard', 'Weather data from Met.no') }}</a>,
- <a href="https://wiki.osmfoundation.org/wiki/Privacy_Policy" target="_blank" rel="noopener">{{ t('dashboard', 'geocoding with Nominatim') }}</a>,
- <a href="https://www.opentopodata.org/#public-api" target="_blank" rel="noopener">{{ t('dashboard', 'elevation data from OpenTopoData') }}</a>.
- </p>
+ <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')">
+ <h2>{{ t('dashboard', 'Weather service') }}</h2>
+ <p>
+ {{ t('dashboard', 'For your privacy, the weather data is requested by your Nextcloud server on your behalf so the weather service receives no personal information.') }}
+ </p>
+ <p class="credits--end">
+ <a href="https://api.met.no/doc/TermsOfService" target="_blank" rel="noopener">{{ t('dashboard', 'Weather data from Met.no') }}</a>,
+ <a href="https://wiki.osmfoundation.org/wiki/Privacy_Policy" target="_blank" rel="noopener">{{ t('dashboard', 'geocoding with Nominatim') }}</a>,
+ <a href="https://www.opentopodata.org/#public-api" target="_blank" rel="noopener">{{ t('dashboard', 'elevation data from OpenTopoData') }}</a>.
+ </p>
+ </div>
</div>
- </Modal>
- </div>
+ </NcModal>
+ </main>
</template>
<script>
-import Vue from 'vue'
-import { loadState } from '@nextcloud/initial-state'
+import { generateUrl, generateOcsUrl } from '@nextcloud/router'
import { getCurrentUser } from '@nextcloud/auth'
-import Modal from '@nextcloud/vue/dist/Components/Modal'
-import Draggable from 'vuedraggable'
+import { loadState } from '@nextcloud/initial-state'
import axios from '@nextcloud/axios'
-import { generateUrl } from '@nextcloud/router'
-import isMobile from './mixins/isMobile'
-import BackgroundSettings from './components/BackgroundSettings'
-import getBackgroundUrl from './helpers/getBackgroundUrl'
+import NcButton from '@nextcloud/vue/components/NcButton'
+import Draggable from 'vuedraggable'
+import NcModal from '@nextcloud/vue/components/NcModal'
+import NcUserStatusIcon from '@nextcloud/vue/components/NcUserStatusIcon'
+import Pencil from 'vue-material-design-icons/Pencil.vue'
+import Vue from 'vue'
+
+import isMobile from './mixins/isMobile.js'
+import ApiDashboardWidget from './components/ApiDashboardWidget.vue'
const panels = loadState('dashboard', 'panels')
const firstRun = loadState('dashboard', 'firstRun')
-const background = loadState('dashboard', 'background')
-const themingDefaultBackground = loadState('dashboard', 'themingDefaultBackground')
-const version = loadState('dashboard', 'version')
-const shippedBackgroundList = loadState('dashboard', 'shippedBackgrounds')
+const birthdate = new Date(loadState('dashboard', 'birthdate'))
+
const statusInfo = {
weather: {
text: t('dashboard', 'Weather'),
@@ -111,20 +139,23 @@ const statusInfo = {
},
status: {
text: t('dashboard', 'Status'),
- icon: 'icon-user-status-online',
},
}
export default {
- name: 'App',
+ name: 'DashboardApp',
components: {
- Modal,
+ ApiDashboardWidget,
+ NcButton,
Draggable,
- BackgroundSettings,
+ NcModal,
+ Pencil,
+ NcUserStatusIcon,
},
mixins: [
isMobile,
],
+
data() {
return {
isAdmin: getCurrentUser().isAdmin,
@@ -142,31 +173,26 @@ export default {
layout: loadState('dashboard', 'layout').filter((panelId) => panels[panelId]),
modal: false,
appStoreUrl: generateUrl('/settings/apps/dashboard'),
+ appStoreEnabled: loadState('dashboard', 'appStoreEnabled', true),
statuses: {},
- background,
- themingDefaultBackground,
- version,
+ apiWidgets: [],
+ apiWidgetItems: {},
+ loadingItems: true,
+ birthdate,
}
},
computed: {
- backgroundImage() {
- return getBackgroundUrl(this.background, this.version, this.themingDefaultBackground)
- },
- backgroundStyle() {
- if ((this.background === 'default' && this.themingDefaultBackground === 'backgroundColor')
- || this.background.match(/#[0-9A-Fa-f]{6}/g)) {
- return null
- }
- return {
- backgroundImage: `url(${this.backgroundImage})`,
- }
- },
greeting() {
const time = this.timer.getHours()
+ const isBirthday = this.birthdate instanceof Date
+ && this.birthdate.getMonth() === this.timer.getMonth()
+ && this.birthdate.getDate() === this.timer.getDate()
// Determine part of the day
let partOfDay
- if (time >= 22 || time < 5) {
+ if (isBirthday) {
+ partOfDay = 'birthday'
+ } else if (time >= 22 || time < 5) {
partOfDay = 'night'
} else if (time >= 18) {
partOfDay = 'evening'
@@ -180,20 +206,24 @@ export default {
const good = {
morning: {
generic: t('dashboard', 'Good morning'),
- withName: t('dashboard', 'Good morning, {name}', { name: this.displayName }),
+ withName: t('dashboard', 'Good morning, {name}', { name: this.displayName }, undefined, { escape: false }),
},
afternoon: {
generic: t('dashboard', 'Good afternoon'),
- withName: t('dashboard', 'Good afternoon, {name}', { name: this.displayName }),
+ withName: t('dashboard', 'Good afternoon, {name}', { name: this.displayName }, undefined, { escape: false }),
},
evening: {
generic: t('dashboard', 'Good evening'),
- withName: t('dashboard', 'Good evening, {name}', { name: this.displayName }),
+ withName: t('dashboard', 'Good evening, {name}', { name: this.displayName }, undefined, { escape: false }),
},
night: {
// Don't use "Good night" as it's not a greeting
generic: t('dashboard', 'Hello'),
- withName: t('dashboard', 'Hello, {name}', { name: this.displayName }),
+ withName: t('dashboard', 'Hello, {name}', { name: this.displayName }, undefined, { escape: false }),
+ },
+ birthday: {
+ generic: t('dashboard', 'Happy birthday 🥳🤩🎂🎉'),
+ withName: t('dashboard', 'Happy birthday, {name} 🥳🤩🎂🎉', { name: this.displayName }, undefined, { escape: false }),
},
}
@@ -201,12 +231,14 @@ export default {
const shouldShowName = this.displayName && this.uid !== this.displayName
return { text: shouldShowName ? good[partOfDay].withName : good[partOfDay].generic }
},
+
isActive() {
return (panel) => this.layout.indexOf(panel.id) > -1
},
isStatusActive() {
- return (status) => !(status in this.enabledStatuses) || this.enabledStatuses[status]
+ return (status) => this.enabledStatuses.findIndex((s) => s === status) !== -1
},
+
sortedAllStatuses() {
return Object.keys(this.allCallbacksStatus).slice().sort(this.sortStatuses)
},
@@ -224,6 +256,7 @@ export default {
return this.registeredStatus.slice().sort(this.sortStatuses)
},
},
+
watch: {
callbacks() {
this.rerenderPanels()
@@ -243,8 +276,29 @@ export default {
}
},
},
+
+ async created() {
+ await this.fetchApiWidgets()
+
+ const apiWidgetIdsToFetch = Object
+ .values(this.apiWidgets)
+ .filter(widget => this.isApiWidgetV2(widget.id) && this.layout.includes(widget.id))
+ .map(widget => widget.id)
+ await Promise.all(apiWidgetIdsToFetch.map(id => this.fetchApiWidgetItems([id], true)))
+
+ for (const widget of Object.values(this.apiWidgets)) {
+ if (widget.reload_interval > 0) {
+ setInterval(async () => {
+ if (!this.layout.includes(widget.id)) {
+ return
+ }
+
+ await this.fetchApiWidgetItems([widget.id], true)
+ }, widget.reload_interval * 1000)
+ }
+ }
+ },
mounted() {
- this.updateGlobalStyles()
this.updateSkipLink()
window.addEventListener('scroll', this.handleScroll)
@@ -259,12 +313,13 @@ export default {
destroyed() {
window.removeEventListener('scroll', this.handleScroll)
},
+
methods: {
/**
* Method to register panels that will be called by the integrating apps
*
* @param {string} app The unique app id for the widget
- * @param {function} callback The callback function to register a panel which gets the DOM element passed as parameter
+ * @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)
@@ -282,6 +337,11 @@ export default {
},
rerenderPanels() {
for (const app in this.callbacks) {
+ // TODO: Properly rerender v2 widgets
+ if (this.isApiWidgetV2(this.panels[app].id)) {
+ continue
+ }
+
const element = this.$refs[app]
if (this.layout.indexOf(app) === -1) {
continue
@@ -300,13 +360,13 @@ export default {
}
},
saveLayout() {
- axios.post(generateUrl('/apps/dashboard/layout'), {
- layout: this.layout.join(','),
+ axios.post(generateOcsUrl('/apps/dashboard/api/v3/layout'), {
+ layout: this.layout,
})
},
saveStatuses() {
- axios.post(generateUrl('/apps/dashboard/statuses'), {
- statuses: JSON.stringify(this.enabledStatuses),
+ axios.post(generateOcsUrl('/apps/dashboard/api/v3/statuses'), {
+ statuses: this.enabledStatuses,
})
},
showModal() {
@@ -320,9 +380,11 @@ export default {
const index = this.layout.indexOf(panel.id)
if (!currentValue && index > -1) {
this.layout.splice(index, 1)
-
} else {
this.layout.push(panel.id)
+ if (this.isApiWidgetV2(panel.id)) {
+ this.fetchApiWidgetItems([panel.id], true)
+ }
}
Vue.set(this.panels[panel.id], 'mounted', false)
this.saveLayout()
@@ -334,24 +396,6 @@ export default {
this.firstRun = false
}, 1000)
},
- updateBackground(data) {
- this.background = data.type === 'custom' || data.type === 'default' ? data.type : data.value
- this.version = data.version
- this.updateGlobalStyles()
- },
- updateGlobalStyles() {
- document.body.setAttribute('data-dashboard-background', this.background)
- if (window.OCA.Theming.inverted) {
- document.body.classList.add('dashboard--inverted')
- }
-
- const shippedBackgroundTheme = shippedBackgroundList[this.background] ? shippedBackgroundList[this.background].theming : 'light'
- if (shippedBackgroundTheme === 'dark') {
- document.body.classList.add('dashboard--dark')
- } else {
- document.body.classList.remove('dashboard--dark')
- }
- },
updateSkipLink() {
// Make sure "Skip to main content" link points to the app content
document.getElementsByClassName('skip-navigation')[0].setAttribute('href', '#app-dashboard')
@@ -364,15 +408,18 @@ export default {
}
},
enableStatus(app) {
- this.enabledStatuses[app] = true
+ this.enabledStatuses.push(app)
this.registerStatus(app, this.allCallbacksStatus[app])
this.saveStatuses()
},
disableStatus(app) {
- this.enabledStatuses[app] = false
- const i = this.registeredStatus.findIndex((s) => s === app)
+ const i = this.enabledStatuses.findIndex((s) => s === app)
if (i !== -1) {
- this.registeredStatus.splice(i, 1)
+ this.enabledStatuses.splice(i, 1)
+ }
+ 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)
@@ -396,6 +443,33 @@ export default {
document.body.classList.remove('dashboard--scrolled')
}
},
+ async fetchApiWidgets() {
+ const { data } = await axios.get(generateOcsUrl('/apps/dashboard/api/v1/widgets'))
+ this.apiWidgets = data.ocs.data
+ },
+ async fetchApiWidgetItems(widgetIds, merge = false) {
+ try {
+ const url = generateOcsUrl('/apps/dashboard/api/v2/widget-items')
+ const params = new URLSearchParams(widgetIds.map(id => ['widgets[]', id]))
+ const response = await axios.get(`${url}?${params.toString()}`)
+ const widgetItems = response.data.ocs.data
+ if (merge) {
+ this.apiWidgetItems = Object.assign({}, this.apiWidgetItems, widgetItems)
+ } else {
+ this.apiWidgetItems = widgetItems
+ }
+ } finally {
+ this.loadingItems = false
+ }
+ },
+ isApiWidgetV2(id) {
+ for (const widget of Object.values(this.apiWidgets)) {
+ if (widget.id === id && widget.item_api_versions.includes(2)) {
+ return true
+ }
+ }
+ return false
+ },
},
}
</script>
@@ -403,37 +477,26 @@ export default {
<style lang="scss" scoped>
#app-dashboard {
width: 100%;
+ min-height: 100%;
background-size: cover;
background-position: center center;
background-repeat: no-repeat;
background-attachment: fixed;
- background-color: var(--color-primary);
- --color-background-translucent: rgba(255, 255, 255, 0.8);
- --background-blur: blur(10px);
-
- #body-user.theme--dark & {
- background-color: var(--color-main-background);
- --color-background-translucent: rgba(24, 24, 24, 0.8);
- }
-
- #body-user.theme--highcontrast & {
- background-color: var(--color-main-background);
- --color-background-translucent: var(--color-main-background);
- }
> h2 {
- color: var(--color-primary-text);
+ // this is shown directly on the background image / color
+ color: var(--color-background-plain-text);
text-align: center;
font-size: 32px;
line-height: 130%;
- padding: 10vh 16px 0px;
+ padding: 1rem 0;
}
}
.panels {
width: auto;
margin: auto;
- max-width: 1500px;
+ max-width: 1800px;
display: flex;
justify-content: center;
flex-direction: row;
@@ -442,13 +505,16 @@ export default {
}
.panel, .panels > div {
+ // Ensure the maxcontrast color is set for the background
+ --color-text-maxcontrast: var(--color-text-maxcontrast-background-blur, var(--color-main-text));
width: 320px;
max-width: 100%;
margin: 16px;
- background-color: var(--color-background-translucent);
- -webkit-backdrop-filter: var(--background-blur);
- backdrop-filter: var(--background-blur);
- border-radius: var(--border-radius-large);
+ align-self: stretch;
+ background-color: var(--color-main-background-blur);
+ -webkit-backdrop-filter: var(--filter-background-blur);
+ backdrop-filter: var(--filter-background-blur);
+ border-radius: var(--border-radius-container-large);
#body-user.theme--highcontrast & {
border: 2px solid var(--color-border);
@@ -465,7 +531,8 @@ export default {
padding: 16px;
cursor: grab;
- &, ::v-deep * {
+ &,
+ :deep(*) {
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
@@ -484,27 +551,41 @@ export default {
> h2 {
display: block;
+ align-items: center;
flex-grow: 1;
margin: 0;
font-size: 20px;
line-height: 24px;
font-weight: bold;
- background-size: 32px;
- background-position: 14px 12px;
- padding: 16px 8px 16px 60px;
+ padding: 16px 8px;
height: 56px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: grab;
+
+ img,
+ span {
+ background-size: 32px;
+ width: 32px;
+ height: 32px;
+ background-position: center;
+ float: left;
+ margin-top: -6px;
+ margin-inline: 6px 16px;
+ }
+
+ img {
+ filter: var(--background-invert-if-dark);
+ }
}
}
& > .panel--content {
margin: 0 16px 16px 16px;
- height: 420px;
+ height: 424px;
// We specifically do not want scrollbars inside widgets
- overflow: hidden;
+ overflow: visible;
}
// No need to extend height of widgets if only one column is shown
@@ -516,10 +597,10 @@ export default {
}
.footer {
- text-align: center;
+ display: flex;
+ justify-content: center;
transition: bottom var(--animation-slow) ease-in-out;
- bottom: 0;
- padding: 44px 0;
+ padding: 1rem 0;
}
.edit-panels {
@@ -527,32 +608,39 @@ export default {
margin:auto;
background-position: 16px center;
padding: 12px 16px;
- padding-left: 36px;
+ padding-inline-start: 36px;
border-radius: var(--border-radius-pill);
max-width: 200px;
opacity: 1;
text-align: center;
}
+.button,
+.button-vue,
.edit-panels,
-.statuses ::v-deep .action-item .action-item__menutoggle,
-.statuses ::v-deep .action-item.action-item--open .action-item__menutoggle {
- background-color: var(--color-background-translucent);
- -webkit-backdrop-filter: var(--background-blur);
- backdrop-filter: var(--background-blur);
+.statuses :deep(.action-item .action-item__menutoggle),
+.statuses :deep(.action-item.action-item--open .action-item__menutoggle) {
+ // Ensure the maxcontrast color is set for the background
+ --color-text-maxcontrast: var(--color-text-maxcontrast-background-blur, var(--color-main-text));
+ background-color: var(--color-main-background-blur);
+ -webkit-backdrop-filter: var(--filter-background-blur);
+ backdrop-filter: var(--filter-background-blur);
+ opacity: 1 !important;
&:hover,
&:focus,
&:active {
- background-color: var(--color-background-hover);
+ background-color: var(--color-background-hover)!important;
+ }
+ &:focus-visible {
+ box-shadow: 0 0 0 4px var(--color-main-background) !important;
+ outline: 2px solid var(--color-main-text) !important;
}
}
.modal__content {
padding: 32px 16px;
- max-height: 70vh;
text-align: center;
- overflow: auto;
ol {
display: flex;
@@ -563,33 +651,56 @@ export default {
}
li {
label {
+ position: relative;
display: block;
- padding: 48px 8px 16px 8px;
+ padding: 48px 16px 14px 16px;
margin: 8px;
- width: 160px;
+ width: 140px;
background-color: var(--color-background-hover);
border: 2px solid var(--color-main-background);
border-radius: var(--border-radius-large);
- background-size: 24px;
- background-position: center 16px;
- text-align: center;
+ text-align: start;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+
+ img,
+ span {
+ position: absolute;
+ top: 16px;
+ width: 24px;
+ height: 24px;
+ background-size: 24px;
+ }
+
+ img {
+ filter: var(--background-invert-if-dark);
+ }
&:hover {
- border-color: var(--color-primary);
+ border-color: var(--color-primary-element);
}
}
+ // Do not invert status icons
+ &:not(.panel-status) label span {
+ filter: var(--background-invert-if-dark);
+ }
+
+ input[type='checkbox'].checkbox + label:before {
+ position: absolute;
+ inset-inline-end: 12px;
+ top: 16px;
+ }
+
input:focus + label {
- border-color: var(--color-primary);
+ border-color: var(--color-primary-element);
}
}
- h3 {
+ h2 {
font-weight: bold;
-
- &:not(:first-of-type) {
- margin-top: 64px;
- }
+ margin-top: 12px;
}
// Adjust design of 'Get more widgets' button
@@ -630,8 +741,21 @@ export default {
flex-wrap: wrap;
margin-bottom: 36px;
- & > div {
+ & > li {
margin: 8px;
}
}
</style>
+<style>
+html, body {
+ background-attachment: fixed;
+}
+
+#body-user #header {
+ position: fixed;
+}
+
+#content {
+ overflow: auto;
+}
+</style>
diff --git a/apps/dashboard/src/components/ApiDashboardWidget.vue b/apps/dashboard/src/components/ApiDashboardWidget.vue
new file mode 100644
index 00000000000..4aa8628fac8
--- /dev/null
+++ b/apps/dashboard/src/components/ApiDashboardWidget.vue
@@ -0,0 +1,116 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+ -->
+<template>
+ <NcDashboardWidget :items="items"
+ :show-more-label="showMoreLabel"
+ :show-more-url="showMoreUrl"
+ :loading="loading"
+ :show-items-and-empty-content="!!halfEmptyContentMessage"
+ :half-empty-content-message="halfEmptyContentMessage">
+ <template #default="{ item }">
+ <ApiDashboardWidgetItem :item="item" :icon-size="iconSize" :rounded-icons="widget.item_icons_round" />
+ </template>
+ <template #empty-content>
+ <NcEmptyContent v-if="items.length === 0"
+ :description="emptyContentMessage">
+ <template #icon>
+ <CheckIcon v-if="emptyContentMessage" :size="65" />
+ </template>
+ <template #action>
+ <NcButton v-if="setupButton" :href="setupButton.link">
+ {{ setupButton.text }}
+ </NcButton>
+ </template>
+ </NcEmptyContent>
+ </template>
+ </NcDashboardWidget>
+</template>
+
+<script>
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcDashboardWidget from '@nextcloud/vue/components/NcDashboardWidget'
+import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
+import CheckIcon from 'vue-material-design-icons/Check.vue'
+import ApiDashboardWidgetItem from './ApiDashboardWidgetItem.vue'
+
+export default {
+ name: 'ApiDashboardWidget',
+ components: {
+ ApiDashboardWidgetItem,
+ CheckIcon,
+ NcDashboardWidget,
+ NcEmptyContent,
+ NcButton,
+ },
+ props: {
+ widget: {
+ type: [Object, undefined],
+ default: undefined,
+ },
+ data: {
+ type: [Object, undefined],
+ default: undefined,
+ },
+ loading: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ iconSize: 44,
+ }
+ },
+ computed: {
+ /** @return {object[]} */
+ items() {
+ return this.data?.items ?? []
+ },
+
+ /** @return {string} */
+ emptyContentMessage() {
+ return this.data?.emptyContentMessage ?? ''
+ },
+
+ /** @return {string} */
+ halfEmptyContentMessage() {
+ return this.data?.halfEmptyContentMessage ?? ''
+ },
+
+ /** @return {object|undefined} */
+ newButton() {
+ // TODO: Render new button in the template
+ // I couldn't find a widget that makes use of the button. Furthermore, there is no convenient
+ // way to render such a button using the official widget component.
+ return this.widget?.buttons?.find(button => button.type === 'new')
+ },
+
+ /** @return {object|undefined} */
+ moreButton() {
+ return this.widget?.buttons?.find(button => button.type === 'more')
+ },
+
+ /** @return {object|undefined} */
+ setupButton() {
+ return this.widget?.buttons?.find(button => button.type === 'setup')
+ },
+
+ /** @return {string|undefined} */
+ showMoreLabel() {
+ return this.moreButton?.text
+ },
+
+ /** @return {string|undefined} */
+ showMoreUrl() {
+ return this.moreButton?.link
+ },
+ },
+ mounted() {
+ const size = window.getComputedStyle(document.body).getPropertyValue('--default-clickable-area')
+ const numeric = Number.parseFloat(size)
+ this.iconSize = Number.isNaN(numeric) ? 44 : numeric
+ },
+}
+</script>
diff --git a/apps/dashboard/src/components/ApiDashboardWidgetItem.vue b/apps/dashboard/src/components/ApiDashboardWidgetItem.vue
new file mode 100644
index 00000000000..2caa7868fb3
--- /dev/null
+++ b/apps/dashboard/src/components/ApiDashboardWidgetItem.vue
@@ -0,0 +1,68 @@
+<!--
+ - SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+ -->
+<script setup lang="ts">
+import { ref } from 'vue'
+import NcAvatar from '@nextcloud/vue/components/NcAvatar'
+import NcDashboardWidgetItem from '@nextcloud/vue/components/NcDashboardWidgetItem'
+import IconFile from 'vue-material-design-icons/File.vue'
+
+defineProps({
+ item: {
+ type: Object,
+ required: true,
+ },
+ iconSize: {
+ type: Number,
+ required: true,
+ },
+ roundedIcons: {
+ type: Boolean,
+ default: true,
+ },
+})
+
+/**
+ * True as soon as the image is loaded
+ */
+const imageLoaded = ref(false)
+/**
+ * True if the image failed to load and we should show a fallback
+ */
+const loadingImageFailed = ref(false)
+</script>
+
+<template>
+ <NcDashboardWidgetItem :target-url="item.link"
+ :overlay-icon-url="item.overlayIconUrl ? item.overlayIconUrl : ''"
+ :main-text="item.title"
+ :sub-text="item.subtitle">
+ <template #avatar>
+ <template v-if="item.iconUrl">
+ <NcAvatar v-if="roundedIcons"
+ :size="iconSize"
+ :url="item.iconUrl" />
+ <template v-else>
+ <img v-show="!loadingImageFailed"
+ alt=""
+ class="api-dashboard-widget-item__icon"
+ :class="{'hidden-visually': !imageLoaded }"
+ :src="item.iconUrl"
+ @error="loadingImageFailed = true"
+ @load="imageLoaded = true">
+ <!-- Placeholder while the image is loaded and also the fallback if the URL is broken -->
+ <IconFile v-if="!imageLoaded"
+ :size="iconSize" />
+ </template>
+ </template>
+ </template>
+ </NcDashboardWidgetItem>
+</template>
+
+<style scoped>
+.api-dashboard-widget-item__icon {
+ height: var(--default-clickable-area);
+ width: var(--default-clickable-area);
+}
+</style>
diff --git a/apps/dashboard/src/components/BackgroundSettings.vue b/apps/dashboard/src/components/BackgroundSettings.vue
deleted file mode 100644
index 691ce12cfd3..00000000000
--- a/apps/dashboard/src/components/BackgroundSettings.vue
+++ /dev/null
@@ -1,191 +0,0 @@
-<!--
- - @copyright Copyright (c) 2020 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>
- <div class="background-selector">
- <button class="background filepicker"
- :class="{ active: background === 'custom' }"
- tabindex="0"
- @click="pickFile">
- {{ t('dashboard', 'Pick from Files') }}
- </button>
- <button class="background default"
- tabindex="0"
- :class="{ 'icon-loading': loading === 'default', active: background === 'default' }"
- @click="setDefault">
- {{ t('dashboard', 'Default images') }}
- </button>
- <button class="background color"
- :class="{ active: background === 'custom' }"
- tabindex="0"
- @click="pickColor">
- {{ t('dashboard', 'Plain background') }}
- </button>
- <button v-for="shippedBackground in shippedBackgrounds"
- :key="shippedBackground.name"
- v-tooltip="shippedBackground.details.attribution"
- :class="{ 'icon-loading': loading === shippedBackground.name, active: background === shippedBackground.name }"
- tabindex="0"
- class="background"
- :style="{ 'background-image': 'url(' + shippedBackground.preview + ')' }"
- @click="setShipped(shippedBackground.name)" />
- </div>
-</template>
-
-<script>
-import axios from '@nextcloud/axios'
-import { generateUrl } from '@nextcloud/router'
-import { loadState } from '@nextcloud/initial-state'
-import getBackgroundUrl from './../helpers/getBackgroundUrl'
-import prefixWithBaseUrl from './../helpers/prefixWithBaseUrl'
-const shippedBackgroundList = loadState('dashboard', 'shippedBackgrounds')
-
-export default {
- name: 'BackgroundSettings',
- props: {
- background: {
- type: String,
- default: 'default',
- },
- themingDefaultBackground: {
- type: String,
- default: '',
- },
- },
- data() {
- return {
- backgroundImage: generateUrl('/apps/dashboard/background') + '?v=' + Date.now(),
- loading: false,
- }
- },
- computed: {
- shippedBackgrounds() {
- return Object.keys(shippedBackgroundList).map((item) => {
- return {
- name: item,
- url: prefixWithBaseUrl(item),
- preview: prefixWithBaseUrl('previews/' + item),
- details: shippedBackgroundList[item],
- }
- })
- },
- },
- methods: {
- async update(data) {
- const background = data.type === 'custom' || data.type === 'default' ? data.type : data.value
- this.backgroundImage = getBackgroundUrl(background, data.version, this.themingDefaultBackground)
- if (data.type === 'color' || (data.type === 'default' && this.themingDefaultBackground === 'backgroundColor')) {
- this.$emit('update:background', data)
- this.loading = false
- return
- }
- const image = new Image()
- image.onload = () => {
- this.$emit('update:background', data)
- this.loading = false
- }
- image.src = this.backgroundImage
- },
- async setDefault() {
- this.loading = 'default'
- const result = await axios.post(generateUrl('/apps/dashboard/background/default'))
- this.update(result.data)
- },
- async setShipped(shipped) {
- this.loading = shipped
- const result = await axios.post(generateUrl('/apps/dashboard/background/shipped'), { value: shipped })
- this.update(result.data)
- },
- async setFile(path) {
- this.loading = 'custom'
- const result = await axios.post(generateUrl('/apps/dashboard/background/custom'), { value: path })
- this.update(result.data)
- },
- async pickColor() {
- this.loading = 'color'
- const color = OCA && OCA.Theming ? OCA.Theming.color : '#0082c9'
- const result = await axios.post(generateUrl('/apps/dashboard/background/color'), { value: color })
- this.update(result.data)
- },
- pickFile() {
- window.OC.dialogs.filepicker(t('dashboard', 'Insert from {productName}', { productName: OC.theme.name }), (path, type) => {
- if (type === OC.dialogs.FILEPICKER_TYPE_CHOOSE) {
- this.setFile(path)
- }
- }, false, ['image/png', 'image/gif', 'image/jpeg', 'image/svg'], true, OC.dialogs.FILEPICKER_TYPE_CHOOSE)
- },
- },
-}
-</script>
-
-<style scoped lang="scss">
-.background-selector {
- display: flex;
- flex-wrap: wrap;
- justify-content: center;
-
- .background {
- width: 176px;
- height: 96px;
- margin: 8px;
- background-size: cover;
- background-position: center center;
- text-align: center;
- border-radius: var(--border-radius-large);
- border: 2px solid var(--color-main-background);
- overflow: hidden;
-
- &.current {
- background-image: var(--color-background-dark);
- }
-
- &.filepicker, &.default, &.color {
- border-color: var(--color-border);
- }
-
- &.color {
- background-color: var(--color-primary);
- color: var(--color-primary-text);
- }
-
- &.active,
- &:hover,
- &:focus {
- border: 2px solid var(--color-primary);
- }
-
- &.active:not(.icon-loading):after {
- background-image: var(--icon-checkmark-fff);
- background-repeat: no-repeat;
- background-position: center;
- background-size: 44px;
- content: '';
- display: block;
- height: 100%;
-
- body.theme--dark & {
- background-image: var(--icon-checkmark-000);
- }
- }
- }
-}
-</style>
diff --git a/apps/dashboard/src/helpers/getBackgroundUrl.js b/apps/dashboard/src/helpers/getBackgroundUrl.js
deleted file mode 100644
index 814b35f01b7..00000000000
--- a/apps/dashboard/src/helpers/getBackgroundUrl.js
+++ /dev/null
@@ -1,39 +0,0 @@
-/*
- * @copyright Copyright (c) 2020 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/>.
- *
- */
-
-import { generateUrl } from '@nextcloud/router'
-import prefixWithBaseUrl from './prefixWithBaseUrl'
-
-export default (background, time = 0, themingDefaultBackground = '') => {
- if (background === 'default') {
- if (themingDefaultBackground && themingDefaultBackground !== 'backgroundColor') {
- return generateUrl('/apps/theming/image/background') + '?v=' + window.OCA.Theming.cacheBuster
- }
- if (window.OCA.Accessibility && window.OCA.Accessibility.theme === 'dark') {
- return prefixWithBaseUrl('eduardo-neves-pedra-azul.jpg')
- }
- return prefixWithBaseUrl('kamil-porembinski-clouds.jpg')
- } else if (background === 'custom') {
- return generateUrl('/apps/dashboard/background') + '?v=' + time
- }
- return prefixWithBaseUrl(background)
-}
diff --git a/apps/dashboard/src/helpers/prefixWithBaseUrl.js b/apps/dashboard/src/helpers/prefixWithBaseUrl.js
deleted file mode 100644
index 7afbf284b79..00000000000
--- a/apps/dashboard/src/helpers/prefixWithBaseUrl.js
+++ /dev/null
@@ -1,24 +0,0 @@
-/*
- * @copyright Copyright (c) 2020 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/>.
- *
- */
-import { generateFilePath } from '@nextcloud/router'
-
-export default (url) => generateFilePath('dashboard', '', 'img/') + url
diff --git a/apps/dashboard/src/main.js b/apps/dashboard/src/main.js
index 93fb55e2d52..68d896a3a17 100644
--- a/apps/dashboard/src/main.js
+++ b/apps/dashboard/src/main.js
@@ -1,23 +1,23 @@
+/**
+ * SPDX-FileCopyrightText: 2016 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import { getCSPNonce } from '@nextcloud/auth'
+import { t } from '@nextcloud/l10n'
+import VTooltip from '@nextcloud/vue/directives/Tooltip'
import Vue from 'vue'
-import App from './App.vue'
-import { translate as t } from '@nextcloud/l10n'
-import VTooltip from '@nextcloud/vue/dist/Directives/Tooltip'
-import { getRequestToken } from '@nextcloud/auth'
-import { generateFilePath } from '@nextcloud/router'
+
+import DashboardApp from './DashboardApp.vue'
// eslint-disable-next-line camelcase
-__webpack_nonce__ = btoa(getRequestToken())
-// eslint-disable-next-line camelcase
-__webpack_public_path__ = generateFilePath('dashboard', '', 'js/')
+__webpack_nonce__ = getCSPNonce()
Vue.directive('Tooltip', VTooltip)
Vue.prototype.t = t
-// FIXME workaround to make the sidebar work
-Object.assign(window.OCA.Files, { App: { fileList: { filesClient: OC.Files.getClient() } } }, window.OCA.Files)
-
-const Dashboard = Vue.extend(App)
+const Dashboard = Vue.extend(DashboardApp)
const Instance = new Dashboard({}).$mount('#app-content-vue')
window.OCA.Dashboard = {
diff --git a/apps/dashboard/src/mixins/isMobile.js b/apps/dashboard/src/mixins/isMobile.js
index cc66af56a82..d4062f8c7e0 100644
--- a/apps/dashboard/src/mixins/isMobile.js
+++ b/apps/dashboard/src/mixins/isMobile.js
@@ -1,23 +1,6 @@
-/*
- * @copyright Copyright (c) 2020 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/>.
- *
+/**
+ * SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
*/
export default {