aboutsummaryrefslogtreecommitdiffstats
path: root/apps/dashboard/src/DashboardApp.vue
diff options
context:
space:
mode:
Diffstat (limited to 'apps/dashboard/src/DashboardApp.vue')
-rw-r--r--apps/dashboard/src/DashboardApp.vue761
1 files changed, 761 insertions, 0 deletions
diff --git a/apps/dashboard/src/DashboardApp.vue b/apps/dashboard/src/DashboardApp.vue
new file mode 100644
index 00000000000..afc874be2c9
--- /dev/null
+++ b/apps/dashboard/src/DashboardApp.vue
@@ -0,0 +1,761 @@
+<!--
+ - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+ -->
+<template>
+ <main id="app-dashboard">
+ <h2>{{ greeting.text }}</h2>
+ <ul class="statuses">
+ <li v-for="status in sortedRegisteredStatus"
+ :id="'status-' + status"
+ :key="status">
+ <div :ref="'status-' + status" />
+ </li>
+ </ul>
+
+ <Draggable v-model="layout"
+ class="panels"
+ v-bind="{swapThreshold: 0.30, delay: 500, delayOnTouchOnly: true, touchStartThreshold: 3}"
+ handle=".panel--header"
+ @end="saveLayout">
+ <template v-for="panelId in layout">
+ <div v-if="isApiWidgetV2(panels[panelId].id)"
+ :key="`${panels[panelId].id}-v2`"
+ class="panel">
+ <div class="panel--header">
+ <h2>
+ <img v-if="apiWidgets[panels[panelId].id].icon_url" :src="apiWidgets[panels[panelId].id].icon_url" alt="">
+ <span v-else :class="apiWidgets[panels[panelId].id].icon_class" aria-hidden="true" />
+ {{ apiWidgets[panels[panelId].id].title }}
+ </h2>
+ </div>
+ <div class="panel--content">
+ <ApiDashboardWidget :widget="apiWidgets[panels[panelId].id]"
+ :data="apiWidgetItems[panels[panelId].id]"
+ :loading="loadingItems" />
+ </div>
+ </div>
+ <div v-else :key="panels[panelId].id" class="panel">
+ <div class="panel--header">
+ <h2>
+ <span :class="panels[panelId].iconClass" aria-hidden="true" />
+ {{ panels[panelId].title }}
+ </h2>
+ </div>
+ <div class="panel--content" :class="{ loading: !panels[panelId].mounted }">
+ <div :ref="panels[panelId].id" :data-id="panels[panelId].id" />
+ </div>
+ </div>
+ </template>
+ </Draggable>
+
+ <div class="footer">
+ <NcButton @click="showModal">
+ <template #icon>
+ <Pencil :size="20" />
+ </template>
+ {{ t('dashboard', 'Customize') }}
+ </NcButton>
+ </div>
+
+ <NcModal v-if="modal" size="large" @close="closeModal">
+ <div class="modal__content">
+ <h2>{{ t('dashboard', 'Edit widgets') }}</h2>
+ <ol class="panels">
+ <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">
+ <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>
+ </ol>
+ <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" :class="'panel-' + panel.id">
+ <input :id="'panel-checkbox-' + panel.id"
+ type="checkbox"
+ class="checkbox"
+ :checked="isActive(panel)"
+ @input="updateCheckbox(panel, $event.target.checked)">
+ <label :for="'panel-checkbox-' + panel.id" :class="{ draggable: isActive(panel) }">
+ <img v-if="panel.iconUrl" alt="" :src="panel.iconUrl">
+ <span v-else :class="panel.iconClass" aria-hidden="true" />
+ {{ panel.title }}
+ </label>
+ </li>
+ </Draggable>
+
+ <a v-if="isAdmin && appStoreEnabled" :href="appStoreUrl" class="button">{{ t('dashboard', 'Get more widgets from the App Store') }}</a>
+
+ <div v-if="statuses.weather && isStatusActive('weather')">
+ <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>
+ </NcModal>
+ </main>
+</template>
+
+<script>
+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/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 birthdate = new Date(loadState('dashboard', 'birthdate'))
+
+const statusInfo = {
+ weather: {
+ text: t('dashboard', 'Weather'),
+ icon: 'icon-weather-status',
+ },
+ status: {
+ text: t('dashboard', 'Status'),
+ },
+}
+
+export default {
+ name: 'DashboardApp',
+ components: {
+ ApiDashboardWidget,
+ NcButton,
+ Draggable,
+ NcModal,
+ Pencil,
+ NcUserStatusIcon,
+ },
+ mixins: [
+ isMobile,
+ ],
+
+ data() {
+ return {
+ isAdmin: getCurrentUser().isAdmin,
+ timer: new Date(),
+ registeredStatus: [],
+ callbacks: {},
+ callbacksStatus: {},
+ allCallbacksStatus: {},
+ statusInfo,
+ enabledStatuses: loadState('dashboard', 'statuses'),
+ panels,
+ firstRun,
+ displayName: getCurrentUser()?.displayName,
+ uid: getCurrentUser()?.uid,
+ layout: loadState('dashboard', 'layout').filter((panelId) => panels[panelId]),
+ modal: false,
+ appStoreUrl: generateUrl('/settings/apps/dashboard'),
+ appStoreEnabled: loadState('dashboard', 'appStoreEnabled', true),
+ statuses: {},
+ 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 (isBirthday) {
+ partOfDay = 'birthday'
+ } else if (time >= 22 || time < 5) {
+ partOfDay = 'night'
+ } else if (time >= 18) {
+ partOfDay = 'evening'
+ } else if (time >= 12) {
+ partOfDay = 'afternoon'
+ } else {
+ partOfDay = 'morning'
+ }
+
+ // Define the greetings
+ const good = {
+ morning: {
+ generic: t('dashboard', 'Good morning'),
+ 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 }, undefined, { escape: false }),
+ },
+ evening: {
+ generic: t('dashboard', 'Good evening'),
+ 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 }, 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
+ 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) => this.enabledStatuses.findIndex((s) => s === status) !== -1
+ },
+
+ sortedAllStatuses() {
+ return Object.keys(this.allCallbacksStatus).slice().sort(this.sortStatuses)
+ },
+ sortedPanels() {
+ return Object.values(this.panels).sort((a, b) => {
+ const indexA = this.layout.indexOf(a.id)
+ const indexB = this.layout.indexOf(b.id)
+ if (indexA === -1 || indexB === -1) {
+ return indexB - indexA || a.id - b.id
+ }
+ return indexA - indexB || a.id - b.id
+ })
+ },
+ sortedRegisteredStatus() {
+ return this.registeredStatus.slice().sort(this.sortStatuses)
+ },
+ },
+
+ watch: {
+ callbacks() {
+ this.rerenderPanels()
+ },
+ callbacksStatus() {
+ for (const app in this.callbacksStatus) {
+ const element = this.$refs['status-' + app]
+ if (this.statuses[app] && this.statuses[app].mounted) {
+ continue
+ }
+ if (element) {
+ this.callbacksStatus[app](element[0])
+ Vue.set(this.statuses, app, { mounted: true })
+ } else {
+ console.error('Failed to register panel in the frontend as no backend data was provided for ' + app)
+ }
+ }
+ },
+ },
+
+ 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.updateSkipLink()
+ window.addEventListener('scroll', this.handleScroll)
+
+ setInterval(() => {
+ this.timer = new Date()
+ }, 30000)
+
+ if (this.firstRun) {
+ window.addEventListener('scroll', this.disableFirstrunHint)
+ }
+ },
+ 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
+ */
+ register(app, callback) {
+ Vue.set(this.callbacks, app, callback)
+ },
+ registerStatus(app, callback) {
+ // always save callbacks in case user enables the status later
+ Vue.set(this.allCallbacksStatus, app, callback)
+ // register only if status is enabled or missing from config
+ if (this.isStatusActive(app)) {
+ this.registeredStatus.push(app)
+ this.$nextTick(() => {
+ Vue.set(this.callbacksStatus, app, callback)
+ })
+ }
+ },
+ 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
+ }
+ if (this.panels[app] && this.panels[app].mounted) {
+ continue
+ }
+ if (element) {
+ this.callbacks[app](element[0], {
+ widget: this.panels[app],
+ })
+ Vue.set(this.panels[app], 'mounted', true)
+ } else {
+ console.error('Failed to register panel in the frontend as no backend data was provided for ' + app)
+ }
+ }
+ },
+ saveLayout() {
+ axios.post(generateOcsUrl('/apps/dashboard/api/v3/layout'), {
+ layout: this.layout,
+ })
+ },
+ saveStatuses() {
+ axios.post(generateOcsUrl('/apps/dashboard/api/v3/statuses'), {
+ statuses: this.enabledStatuses,
+ })
+ },
+ showModal() {
+ this.modal = true
+ this.firstRun = false
+ },
+ closeModal() {
+ this.modal = false
+ },
+ updateCheckbox(panel, currentValue) {
+ 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()
+ this.$nextTick(() => this.rerenderPanels())
+ },
+ disableFirstrunHint() {
+ window.removeEventListener('scroll', this.disableFirstrunHint)
+ setTimeout(() => {
+ this.firstRun = false
+ }, 1000)
+ },
+ updateSkipLink() {
+ // Make sure "Skip to main content" link points to the app content
+ document.getElementsByClassName('skip-navigation')[0].setAttribute('href', '#app-dashboard')
+ },
+ updateStatusCheckbox(app, checked) {
+ if (checked) {
+ this.enableStatus(app)
+ } else {
+ this.disableStatus(app)
+ }
+ },
+ enableStatus(app) {
+ this.enabledStatuses.push(app)
+ this.registerStatus(app, this.allCallbacksStatus[app])
+ this.saveStatuses()
+ },
+ disableStatus(app) {
+ const i = this.enabledStatuses.findIndex((s) => s === app)
+ if (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)
+ })
+ }
+ this.saveStatuses()
+ },
+ sortStatuses(a, b) {
+ const al = a.toLowerCase()
+ const bl = b.toLowerCase()
+ return al > bl
+ ? 1
+ : al < bl
+ ? -1
+ : 0
+ },
+ handleScroll() {
+ if (window.scrollY > 70) {
+ document.body.classList.add('dashboard--scrolled')
+ } else {
+ 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>
+
+<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;
+
+ > h2 {
+ // 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: 1rem 0;
+ }
+}
+
+.panels {
+ width: auto;
+ margin: auto;
+ max-width: 1800px;
+ display: flex;
+ justify-content: center;
+ flex-direction: row;
+ align-items: flex-start;
+ flex-wrap: wrap;
+}
+
+.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;
+ 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);
+ }
+
+ &.sortable-ghost {
+ opacity: 0.1;
+ }
+
+ & > .panel--header {
+ display: flex;
+ z-index: 1;
+ top: 50px;
+ padding: 16px;
+ cursor: grab;
+
+ &,
+ :deep(*) {
+ -webkit-touch-callout: none;
+ -webkit-user-select: none;
+ -khtml-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+ }
+
+ &:active {
+ cursor: grabbing;
+ }
+
+ a {
+ flex-grow: 1;
+ }
+
+ > h2 {
+ display: block;
+ align-items: center;
+ flex-grow: 1;
+ margin: 0;
+ font-size: 20px;
+ line-height: 24px;
+ font-weight: bold;
+ 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: 424px;
+ // We specifically do not want scrollbars inside widgets
+ overflow: visible;
+ }
+
+ // No need to extend height of widgets if only one column is shown
+ @media only screen and (max-width: 709px) {
+ & > .panel--content {
+ height: auto;
+ }
+ }
+}
+
+.footer {
+ display: flex;
+ justify-content: center;
+ transition: bottom var(--animation-slow) ease-in-out;
+ padding: 1rem 0;
+}
+
+.edit-panels {
+ display: inline-block;
+ margin:auto;
+ background-position: 16px center;
+ padding: 12px 16px;
+ padding-inline-start: 36px;
+ border-radius: var(--border-radius-pill);
+ max-width: 200px;
+ opacity: 1;
+ text-align: center;
+}
+
+.button,
+.button-vue,
+.edit-panels,
+.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)!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;
+ text-align: center;
+
+ ol {
+ display: flex;
+ flex-direction: row;
+ justify-content: center;
+ list-style-type: none;
+ padding-bottom: 16px;
+ }
+ li {
+ label {
+ position: relative;
+ display: block;
+ padding: 48px 16px 14px 16px;
+ margin: 8px;
+ width: 140px;
+ background-color: var(--color-background-hover);
+ border: 2px solid var(--color-main-background);
+ border-radius: var(--border-radius-large);
+ 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-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-element);
+ }
+ }
+
+ h2 {
+ font-weight: bold;
+ margin-top: 12px;
+ }
+
+ // Adjust design of 'Get more widgets' button
+ .button {
+ display: inline-block;
+ padding: 10px 16px;
+ margin: 0;
+ }
+
+ p {
+ max-width: 650px;
+ margin: 0 auto;
+
+ a:hover,
+ a:focus {
+ border-bottom: 2px solid var(--color-border);
+ }
+ }
+
+ .credits--end {
+ padding-bottom: 32px;
+ color: var(--color-text-maxcontrast);
+
+ a {
+ color: var(--color-text-maxcontrast);
+ }
+ }
+}
+
+.flip-list-move {
+ transition: transform var(--animation-slow);
+}
+
+.statuses {
+ display: flex;
+ flex-direction: row;
+ justify-content: center;
+ flex-wrap: wrap;
+ margin-bottom: 36px;
+
+ & > li {
+ margin: 8px;
+ }
+}
+</style>
+<style>
+html, body {
+ background-attachment: fixed;
+}
+
+#body-user #header {
+ position: fixed;
+}
+
+#content {
+ overflow: auto;
+}
+</style>