diff options
Diffstat (limited to 'apps/dashboard/src')
-rw-r--r-- | apps/dashboard/src/DashboardApp.vue | 115 | ||||
-rw-r--r-- | apps/dashboard/src/components/ApiDashboardWidget.vue | 64 | ||||
-rw-r--r-- | apps/dashboard/src/components/ApiDashboardWidgetItem.vue | 68 | ||||
-rw-r--r-- | apps/dashboard/src/main.js | 30 | ||||
-rw-r--r-- | apps/dashboard/src/mixins/isMobile.js | 21 |
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 { |