diff options
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.vue | 116 | ||||
-rw-r--r-- | apps/dashboard/src/components/ApiDashboardWidgetItem.vue | 68 | ||||
-rw-r--r-- | apps/dashboard/src/components/BackgroundSettings.vue | 191 | ||||
-rw-r--r-- | apps/dashboard/src/helpers/getBackgroundUrl.js | 39 | ||||
-rw-r--r-- | apps/dashboard/src/helpers/prefixWithBaseUrl.js | 24 | ||||
-rw-r--r-- | apps/dashboard/src/main.js | 24 | ||||
-rw-r--r-- | apps/dashboard/src/mixins/isMobile.js | 23 |
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 { |