summaryrefslogtreecommitdiffstats
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.vue678
1 files changed, 678 insertions, 0 deletions
diff --git a/apps/dashboard/src/DashboardApp.vue b/apps/dashboard/src/DashboardApp.vue
new file mode 100644
index 00000000000..46de7b58827
--- /dev/null
+++ b/apps/dashboard/src/DashboardApp.vue
@@ -0,0 +1,678 @@
+<template>
+ <div id="app-dashboard" :style="backgroundStyle">
+ <h2>{{ greeting.text }}</h2>
+ <ul class="statuses">
+ <div v-for="status in sortedRegisteredStatus"
+ :id="'status-' + status"
+ :key="status">
+ <div :ref="'status-' + status" />
+ </div>
+ </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>
+ <div :class="panels[panelId].iconClass" role="img" />
+ {{ 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>
+ </Draggable>
+
+ <div class="footer">
+ <Button @click="showModal">
+ <template #icon>
+ <Pencil :size="20" />
+ </template>
+ {{ t('dashboard', 'Customize') }}
+ </Button>
+ </div>
+
+ <Modal v-if="modal" size="large" @close="closeModal">
+ <div class="modal__content">
+ <h3>{{ t('dashboard', 'Edit widgets') }}</h3>
+ <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">
+ <div :class="statusInfo[status].icon" role="img" />
+ {{ 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) }">
+ <div :class="panel.iconClass" role="img" />
+ {{ 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>
+ </div>
+ </Modal>
+ </div>
+</template>
+
+<script>
+import { generateUrl } from '@nextcloud/router'
+import { getCurrentUser } from '@nextcloud/auth'
+import { loadState } from '@nextcloud/initial-state'
+import axios from '@nextcloud/axios'
+import Button from '@nextcloud/vue/dist/Components/Button'
+import Draggable from 'vuedraggable'
+import Modal from '@nextcloud/vue/dist/Components/Modal'
+import Pencil from 'vue-material-design-icons/Pencil.vue'
+import Vue from 'vue'
+
+import isMobile from './mixins/isMobile'
+import BackgroundSettings from './components/BackgroundSettings'
+import getBackgroundUrl from './helpers/getBackgroundUrl'
+
+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 statusInfo = {
+ weather: {
+ text: t('dashboard', 'Weather'),
+ icon: 'icon-weather-status',
+ },
+ status: {
+ text: t('dashboard', 'Status'),
+ icon: 'icon-user-status-online',
+ },
+}
+
+export default {
+ name: 'DashboardApp',
+ components: {
+ BackgroundSettings,
+ Button,
+ Draggable,
+ Modal,
+ Pencil,
+ },
+ 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'),
+ statuses: {},
+ background,
+ themingDefaultBackground,
+ version,
+ }
+ },
+ 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()
+
+ // Determine part of the day
+ let partOfDay
+ 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 }),
+ },
+ }
+
+ // 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) => !(status in this.enabledStatuses) || this.enabledStatuses[status]
+ },
+
+ 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)
+ }
+ }
+ },
+ },
+
+ mounted() {
+ this.updateGlobalStyles()
+ 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) {
+ 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(generateUrl('/apps/dashboard/layout'), {
+ layout: this.layout.join(','),
+ })
+ },
+ saveStatuses() {
+ axios.post(generateUrl('/apps/dashboard/statuses'), {
+ statuses: JSON.stringify(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)
+ }
+ 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)
+ },
+ updateBackground(data) {
+ this.background = data.type === 'custom' || data.type === 'default' ? data.type : data.value
+ this.version = data.version
+ this.updateGlobalStyles()
+ },
+ updateGlobalStyles() {
+ // Override primary-invert-if-bright and color-primary-text if background is set
+ const isBackgroundBright = shippedBackgroundList[this.background]?.theming === 'dark'
+ if (isBackgroundBright) {
+ document.querySelector('#header').style.setProperty('--primary-invert-if-bright', 'invert(100%)')
+ document.querySelector('#header').style.setProperty('--color-primary-text', '#000000')
+ } else {
+ document.querySelector('#header').style.removeProperty('--primary-invert-if-bright')
+ document.querySelector('#header').style.removeProperty('--color-primary-text')
+ }
+ },
+ 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[app] = true
+ this.registerStatus(app, this.allCallbacksStatus[app])
+ this.saveStatuses()
+ },
+ disableStatus(app) {
+ this.enabledStatuses[app] = false
+ const i = this.registeredStatus.findIndex((s) => s === app)
+ if (i !== -1) {
+ this.registeredStatus.splice(i, 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')
+ }
+ },
+ },
+}
+</script>
+
+<style lang="scss" scoped>
+#app-dashboard {
+ width: 100%;
+ min-height: 100vh;
+ background-size: cover;
+ background-position: center center;
+ background-repeat: no-repeat;
+ background-attachment: fixed;
+ background-color: var(--color-primary);
+ --color-background-translucent: rgba(var(--color-main-background-rgb), 0.8);
+ --background-blur: blur(10px);
+
+ > h2 {
+ color: var(--color-primary-text);
+ text-align: center;
+ font-size: 32px;
+ line-height: 130%;
+ padding: 10vh 16px 0px;
+ }
+}
+
+.panels {
+ width: auto;
+ margin: auto;
+ max-width: 1500px;
+ display: flex;
+ justify-content: center;
+ flex-direction: row;
+ align-items: flex-start;
+ flex-wrap: wrap;
+}
+
+.panel, .panels > div {
+ 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);
+
+ #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;
+
+ &, ::v-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: flex;
+ 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;
+ div {
+ background-size: 32px;
+ width: 32px;
+ height: 32px;
+ margin-right: 16px;
+ background-position: center;
+ filter: var(--background-invert-if-dark);
+ }
+ }
+ }
+
+ & > .panel--content {
+ margin: 0 16px 16px 16px;
+ height: 420px;
+ // We specifically do not want scrollbars inside widgets
+ overflow: hidden;
+ }
+
+ // 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;
+ bottom: 0;
+ padding: 44px 0;
+}
+
+.edit-panels {
+ display: inline-block;
+ margin:auto;
+ background-position: 16px center;
+ padding: 12px 16px;
+ padding-left: 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);
+ opacity: 1 !important;
+
+ &:hover,
+ &:focus,
+ &:active {
+ background-color: var(--color-background-hover)!important;
+ }
+ &:focus-visible {
+ border: 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: left;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+
+ div {
+ position: absolute;
+ top: 16px;
+ width: 24px;
+ height: 24px;
+ background-size: 24px;
+ }
+
+ &:hover {
+ border-color: var(--color-primary);
+ }
+ }
+
+ // Do not invert status icons
+ &:not(.panel-status) label div {
+ filter: var(--background-invert-if-dark);
+ }
+
+ input[type='checkbox'].checkbox + label:before {
+ position: absolute;
+ right: 12px;
+ top: 16px;
+ }
+
+ input:focus + label {
+ border-color: var(--color-primary);
+ }
+ }
+
+ h3 {
+ font-weight: bold;
+
+ &:not(:first-of-type) {
+ margin-top: 64px;
+ }
+ }
+
+ // 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;
+
+ & > div {
+ margin: 8px;
+ }
+}
+</style>