nextcloud/apps/dashboard/src/App.vue

497 lines
12 KiB
Vue
Raw Normal View History

<template>
<div id="app-dashboard" :style="backgroundStyle">
<h2>{{ greeting.text }}</h2>
<div class="statuses">
<div v-for="status in registeredStatus"
:id="'status-' + status"
:key="status"
:ref="'status-' + status" />
</div>
<Draggable v-model="layout"
class="panels"
handle=".panel--header"
@end="saveLayout">
<div v-for="panelId in layout" :key="panels[panelId].id" class="panel">
<div class="panel--header">
<h3 :class="panels[panelId].iconClass">
{{ panels[panelId].title }}
</h3>
</div>
<div class="panel--content">
<div :ref="panels[panelId].id" :data-id="panels[panelId].id" />
</div>
</div>
</Draggable>
<div class="footer"
:class="{ firstrun: firstRun }">
<a v-tooltip="tooltip"
class="edit-panels icon-rename"
tabindex="0"
@click="showModal"
@keyup.enter="showModal"
@keyup.space="showModal">{{ t('dashboard', 'Customize') }}</a>
</div>
<Modal v-if="modal" @close="closeModal">
<div class="modal__content">
<h3>{{ t('dashboard', 'Edit widgets') }}</h3>
<Draggable v-model="layout"
class="panels"
tag="ol"
handle=".draggable"
@end="saveLayout">
<li v-for="panel in sortedPanels" :key="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">
{{ panel.title }}
</label>
</li>
</Draggable>
<a :href="appStoreUrl" class="button">{{ t('dashboard', 'Get more widgets from the app store') }}</a>
<h3>{{ t('dashboard', 'Change background image') }}</h3>
<BackgroundSettings @updateBackground="updateBackground" />
<h3>{{ t('dashboard', 'Credits') }}</h3>
<p>{{ t('dashboard', 'Photos') }}: <a href="https://www.flickr.com/photos/paszczak000/8715851521/" target="_blank" rel="noopener">Clouds (Kamil Porembiński)</a>, <a href="https://www.flickr.com/photos/148302424@N05/36591009215/" target="_blank" rel="noopener">Un beau soir dété (Tanguy Domenge)</a>.</p>
</div>
</Modal>
</div>
</template>
<script>
import Vue from 'vue'
import { loadState } from '@nextcloud/initial-state'
import { getCurrentUser } from '@nextcloud/auth'
import { Modal } from '@nextcloud/vue'
import Draggable from 'vuedraggable'
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 prefixWithBaseUrl from './helpers/prefixWithBaseUrl'
const panels = loadState('dashboard', 'panels')
const firstRun = loadState('dashboard', 'firstRun')
const background = loadState('dashboard', 'background')
const version = loadState('dashboard', 'version')
export default {
name: 'App',
components: {
Modal,
Draggable,
BackgroundSettings,
},
mixins: [
isMobile,
],
data() {
return {
timer: new Date(),
registeredStatus: [],
callbacks: {},
callbacksStatus: {},
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,
version,
defaultBackground: window.OCA.Accessibility.theme === 'dark' ? prefixWithBaseUrl('flickr-148302424@N05-36591009215.jpg?v=1') : prefixWithBaseUrl('flickr-paszczak000-8715851521.jpg?v=1'),
}
},
computed: {
backgroundImage() {
return getBackgroundUrl(this.background, this.version)
},
backgroundStyle() {
if (this.background.match(/#[0-9A-Fa-f]{6}/g)) {
return null
}
return {
backgroundImage: `url(${this.backgroundImage})`,
}
},
tooltip() {
if (!this.firstRun) {
return null
}
return {
content: t('dashboard', 'Adjust the dashboard to your needs'),
placement: 'top',
show: true,
trigger: 'manual',
}
},
greeting() {
const time = this.timer.getHours()
const shouldShowName = this.displayName && this.uid !== this.displayName
if (time > 18) {
return { text: shouldShowName ? t('dashboard', 'Good evening, {name}', { name: this.displayName }) : t('dashboard', 'Good evening') }
}
if (time > 12) {
return { text: shouldShowName ? t('dashboard', 'Good afternoon, {name}', { name: this.displayName }) : t('dashboard', 'Good afternoon') }
}
if (time > 5) {
return { text: shouldShowName ? t('dashboard', 'Good morning, {name}', { name: this.displayName }) : t('dashboard', 'Good morning') }
}
return { text: shouldShowName ? t('dashboard', 'Good night, {name}', { name: this.displayName }) : t('dashboard', 'Good night') }
},
isActive() {
return (panel) => this.layout.indexOf(panel.id) > -1
},
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
})
},
},
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)
}
}
},
backgroundImage: {
immediate: true,
handler() {
const header = document.getElementById('header')
header.style.backgroundImage = `url(${this.backgroundImage})`
},
},
},
mounted() {
setInterval(() => {
this.timer = new Date()
}, 30000)
if (this.firstRun) {
window.addEventListener('scroll', this.disableFirstrunHint)
}
},
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) {
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(','),
})
},
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
},
},
}
</script>
<style lang="scss">
// Show Dashboard background image beneath header
#body-user #header {
background-size: cover;
background-position: center 50%;
background-repeat: no-repeat;
background-attachment: fixed;
}
#content {
padding-top: 0;
}
// Hide triangle indicators from navigation since they are out of place without the header bar
#appmenu li a.active::before,
#appmenu li:hover a::before,
#appmenu li:hover a.active::before,
#appmenu li a:focus::before {
display: none;
}
</style>
<style lang="scss" scoped>
#app-dashboard {
width: 100%;
background-size: cover;
background-position: center center;
background-repeat: no-repeat;
background-attachment: fixed;
#body-user:not(.dark) & {
background-color: var(--color-primary);
}
#body-user.dark & {
background-color: var(--color-main-background);
}
}
h2 {
color: var(--color-primary-text);
text-align: center;
font-size: 32px;
line-height: 130%;
padding: 120px 16px 0px;
}
.statuses {
::v-deep #user-status-menu-item__subheader>button {
backdrop-filter: blur(10px);
background-color: rgba(255, 255, 255, 0.8);
#body-user.dark & {
background-color: rgba(24, 24, 24, 0.8);
}
}
}
.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: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(10px);
border-radius: var(--border-radius-large);
#body-user.dark & {
background-color: rgba(24, 24, 24, 0.8);
}
&.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;
}
h3 {
display: block;
flex-grow: 1;
margin: 0;
font-size: 20px;
font-weight: bold;
background-size: 32px;
background-position: 14px 12px;
padding: 16px 8px 16px 60px;
cursor: grab;
}
}
& > .panel--content {
margin: 0 16px 16px 16px;
height: 420px;
overflow: auto;
}
}
.footer {
text-align: center;
transition: bottom var(--animation-slow) ease-in-out;
bottom: 0;
padding: 44px 0;
&.firstrun {
position: sticky;
bottom: 10px;
}
}
.edit-panels {
display: inline-block;
margin:auto;
background-position: 16px center;
padding: 12px 16px;
padding-left: 36px;
background-color: var(--color-main-background);
border-radius: var(--border-radius-pill);
max-width: 200px;
opacity: 1;
text-align: center;
&:focus,
&:hover {
opacity: 1;
background-color: var(--color-background-hover);
}
}
.modal__content {
padding: 32px 16px;
max-height: 70vh;
text-align: center;
overflow: auto;
ol {
display: flex;
flex-direction: row;
justify-content: center;
list-style-type: none;
padding-bottom: 16px;
}
li {
label {
display: block;
padding: 48px 8px 16px 8px;
margin: 8px;
width: 160px;
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;
&:hover {
border-color: var(--color-primary);
}
}
input:focus + label {
border-color: var(--color-primary);
}
}
h3 {
font-weight: bold;
&:not(:first-of-type) {
margin-top: 64px;
}
}
}
.flip-list-move {
transition: transform var(--animation-slow);
}
.statuses {
display: flex;
flex-direction: row;
justify-content: center;
margin-bottom: 40px;
& > div {
max-width: 200px;
}
}
</style>