aboutsummaryrefslogtreecommitdiffstats
path: root/apps/dashboard/src
diff options
context:
space:
mode:
Diffstat (limited to 'apps/dashboard/src')
-rw-r--r--apps/dashboard/src/DashboardApp.vue115
-rw-r--r--apps/dashboard/src/components/ApiDashboardWidget.vue64
-rw-r--r--apps/dashboard/src/components/ApiDashboardWidgetItem.vue68
-rw-r--r--apps/dashboard/src/main.js30
-rw-r--r--apps/dashboard/src/mixins/isMobile.js21
5 files changed, 167 insertions, 131 deletions
diff --git a/apps/dashboard/src/DashboardApp.vue b/apps/dashboard/src/DashboardApp.vue
index 18dc0a6c467..afc874be2c9 100644
--- a/apps/dashboard/src/DashboardApp.vue
+++ b/apps/dashboard/src/DashboardApp.vue
@@ -1,5 +1,9 @@
+<!--
+ - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+ -->
<template>
- <div id="app-dashboard">
+ <main id="app-dashboard">
<h2>{{ greeting.text }}</h2>
<ul class="statuses">
<li v-for="status in sortedRegisteredStatus"
@@ -20,15 +24,10 @@
class="panel">
<div class="panel--header">
<h2>
- <span :aria-labelledby="`panel-${panels[panelId].id}--header--icon--description`"
- aria-hidden="true"
- :class="apiWidgets[panels[panelId].id].icon_class"
- role="img" />
+ <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>
- <span :id="`panel-${panels[panelId].id}--header--icon--description`" class="hidden-visually">
- {{ t('dashboard', '"{title} icon"', { title: apiWidgets[panels[panelId].id].title }) }}
- </span>
</div>
<div class="panel--content">
<ApiDashboardWidget :widget="apiWidgets[panels[panelId].id]"
@@ -39,13 +38,9 @@
<div v-else :key="panels[panelId].id" class="panel">
<div class="panel--header">
<h2>
- <span :aria-labelledby="`panel-${panels[panelId].id}--header--icon--description`"
- aria-hidden="true"
- :class="panels[panelId].iconClass"
- role="img" />
+ <span :class="panels[panelId].iconClass" aria-hidden="true" />
{{ panels[panelId].title }}
</h2>
- <span :id="`panel-${panels[panelId].id}--header--icon--description`" class="hidden-visually"> {{ t('dashboard', '"{title} icon"', { title: panels[panelId].title }) }} </span>
</div>
<div class="panel--content" :class="{ loading: !panels[panelId].mounted }">
<div :ref="panels[panelId].id" :data-id="panels[panelId].id" />
@@ -93,7 +88,8 @@
:checked="isActive(panel)"
@input="updateCheckbox(panel, $event.target.checked)">
<label :for="'panel-checkbox-' + panel.id" :class="{ draggable: isActive(panel) }">
- <span :class="panel.iconClass" aria-hidden="true" />
+ <img v-if="panel.iconUrl" alt="" :src="panel.iconUrl">
+ <span v-else :class="panel.iconClass" aria-hidden="true" />
{{ panel.title }}
</label>
</li>
@@ -114,7 +110,7 @@
</div>
</div>
</NcModal>
- </div>
+ </main>
</template>
<script>
@@ -122,10 +118,10 @@ import { generateUrl, generateOcsUrl } from '@nextcloud/router'
import { getCurrentUser } from '@nextcloud/auth'
import { loadState } from '@nextcloud/initial-state'
import axios from '@nextcloud/axios'
-import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
+import NcButton from '@nextcloud/vue/components/NcButton'
import Draggable from 'vuedraggable'
-import NcModal from '@nextcloud/vue/dist/Components/NcModal.js'
-import NcUserStatusIcon from '@nextcloud/vue/dist/Components/NcUserStatusIcon.js'
+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'
@@ -134,6 +130,7 @@ import ApiDashboardWidget from './components/ApiDashboardWidget.vue'
const panels = loadState('dashboard', 'panels')
const firstRun = loadState('dashboard', 'firstRun')
+const birthdate = new Date(loadState('dashboard', 'birthdate'))
const statusInfo = {
weather: {
@@ -181,15 +178,21 @@ export default {
apiWidgets: [],
apiWidgetItems: {},
loadingItems: true,
+ birthdate,
}
},
computed: {
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'
@@ -218,6 +221,10 @@ export default {
generic: t('dashboard', 'Hello'),
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 }),
+ },
}
// Figure out which greeting to show
@@ -229,7 +236,7 @@ export default {
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() {
@@ -275,13 +282,17 @@ export default {
const apiWidgetIdsToFetch = Object
.values(this.apiWidgets)
- .filter(widget => this.isApiWidgetV2(widget.id))
+ .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)
}
@@ -349,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() {
@@ -369,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()
@@ -395,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)
@@ -428,8 +444,8 @@ export default {
}
},
async fetchApiWidgets() {
- const response = await axios.get(generateOcsUrl('/apps/dashboard/api/v1/widgets'))
- this.apiWidgets = response.data.ocs.data
+ const { data } = await axios.get(generateOcsUrl('/apps/dashboard/api/v1/widgets'))
+ this.apiWidgets = data.ocs.data
},
async fetchApiWidgetItems(widgetIds, merge = false) {
try {
@@ -468,8 +484,8 @@ export default {
background-attachment: fixed;
> h2 {
- // this is shown directly on the background which has `color-primary`, so we need `color-primary-text`
- 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%;
@@ -491,7 +507,6 @@ 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;
@@ -499,7 +514,7 @@ export default {
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-rounded);
+ border-radius: var(--border-radius-container-large);
#body-user.theme--highcontrast & {
border: 2px solid var(--color-border);
@@ -516,7 +531,8 @@ export default {
padding: 16px;
cursor: grab;
- &, ::v-deep * {
+ &,
+ :deep(*) {
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
@@ -547,15 +563,20 @@ export default {
overflow: hidden;
text-overflow: ellipsis;
cursor: grab;
+
+ img,
span {
background-size: 32px;
width: 32px;
height: 32px;
- margin-right: 16px;
background-position: center;
float: left;
margin-top: -6px;
- margin-left: 6px;
+ margin-inline: 6px 16px;
+ }
+
+ img {
+ filter: var(--background-invert-if-dark);
}
}
}
@@ -587,7 +608,7 @@ 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;
@@ -597,11 +618,10 @@ export default {
.button,
.button-vue,
.edit-panels,
-.statuses ::v-deep .action-item .action-item__menutoggle,
-.statuses ::v-deep .action-item.action-item--open .action-item__menutoggle {
+.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);
@@ -639,11 +659,12 @@ export default {
background-color: var(--color-background-hover);
border: 2px solid var(--color-main-background);
border-radius: var(--border-radius-large);
- text-align: left;
+ text-align: start;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
+ img,
span {
position: absolute;
top: 16px;
@@ -652,6 +673,10 @@ export default {
background-size: 24px;
}
+ img {
+ filter: var(--background-invert-if-dark);
+ }
+
&:hover {
border-color: var(--color-primary-element);
}
@@ -664,7 +689,7 @@ export default {
input[type='checkbox'].checkbox + label:before {
position: absolute;
- right: 12px;
+ inset-inline-end: 12px;
top: 16px;
}
diff --git a/apps/dashboard/src/components/ApiDashboardWidget.vue b/apps/dashboard/src/components/ApiDashboardWidget.vue
index daa97fc5428..4aa8628fac8 100644
--- a/apps/dashboard/src/components/ApiDashboardWidget.vue
+++ b/apps/dashboard/src/components/ApiDashboardWidget.vue
@@ -1,25 +1,7 @@
<!--
- - @copyright Copyright (c) 2023 Richard Steinmetz <richard@steinmetz.cloud>
- -
- - @author Richard Steinmetz <richard@steinmetz.cloud>
- -
- - @license AGPL-3.0-or-later
- -
- - This program is free software: you can redistribute it and/or modify
- - it under the terms of the GNU 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 General Public License for more details.
- -
- - You should have received a copy of the GNU General Public License
- - along with this program. If not, see <http://www.gnu.org/licenses/>.
- -
- -->
-
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+ -->
<template>
<NcDashboardWidget :items="items"
:show-more-label="showMoreLabel"
@@ -28,16 +10,7 @@
:show-items-and-empty-content="!!halfEmptyContentMessage"
:half-empty-content-message="halfEmptyContentMessage">
<template #default="{ item }">
- <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 :size="44" :url="item.iconUrl" />
- </template>
- </template>
- </NcDashboardWidgetItem>
+ <ApiDashboardWidgetItem :item="item" :icon-size="iconSize" :rounded-icons="widget.item_icons_round" />
</template>
<template #empty-content>
<NcEmptyContent v-if="items.length === 0"
@@ -56,24 +29,20 @@
</template>
<script>
-import {
- NcAvatar,
- NcDashboardWidget,
- NcDashboardWidgetItem,
- NcEmptyContent,
- NcButton,
-} from '@nextcloud/vue'
+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: {
- NcAvatar,
+ ApiDashboardWidgetItem,
+ CheckIcon,
NcDashboardWidget,
- NcDashboardWidgetItem,
NcEmptyContent,
NcButton,
- CheckIcon,
},
props: {
widget: {
@@ -89,6 +58,11 @@ export default {
required: true,
},
},
+ data() {
+ return {
+ iconSize: 44,
+ }
+ },
computed: {
/** @return {object[]} */
items() {
@@ -133,8 +107,10 @@ export default {
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>
-
-<style lang="scss" scoped>
-</style>
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/main.js b/apps/dashboard/src/main.js
index 18374f823c1..68d896a3a17 100644
--- a/apps/dashboard/src/main.js
+++ b/apps/dashboard/src/main.js
@@ -1,33 +1,17 @@
/**
- * @copyright Copyright (c) 2016 Julius Härtl <jus@bitgrid.net>
- *
- * @author Julius Härtl <jus@bitgrid.net>
- *
- * @license AGPL-3.0-or-later
- *
- * 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: 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 DashboardApp from './DashboardApp.vue'
-import { translate as t } from '@nextcloud/l10n'
-import VTooltip from '@nextcloud/vue/dist/Directives/Tooltip.js'
-import { getRequestToken } from '@nextcloud/auth'
// eslint-disable-next-line camelcase
-__webpack_nonce__ = btoa(getRequestToken())
+__webpack_nonce__ = getCSPNonce()
Vue.directive('Tooltip', VTooltip)
diff --git a/apps/dashboard/src/mixins/isMobile.js b/apps/dashboard/src/mixins/isMobile.js
index 6bae7219fe6..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 AGPL-3.0-or-later
- *
- * 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 {