You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

DashboardApp.vue 15KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643
  1. <template>
  2. <div id="app-dashboard">
  3. <h2>{{ greeting.text }}</h2>
  4. <ul class="statuses">
  5. <div v-for="status in sortedRegisteredStatus"
  6. :id="'status-' + status"
  7. :key="status">
  8. <div :ref="'status-' + status" />
  9. </div>
  10. </ul>
  11. <Draggable v-model="layout"
  12. class="panels"
  13. v-bind="{swapThreshold: 0.30, delay: 500, delayOnTouchOnly: true, touchStartThreshold: 3}"
  14. handle=".panel--header"
  15. @end="saveLayout">
  16. <div v-for="panelId in layout" :key="panels[panelId].id" class="panel">
  17. <div class="panel--header">
  18. <h2>
  19. <div :class="panels[panelId].iconClass" role="img" />
  20. {{ panels[panelId].title }}
  21. </h2>
  22. </div>
  23. <div class="panel--content" :class="{ loading: !panels[panelId].mounted }">
  24. <div :ref="panels[panelId].id" :data-id="panels[panelId].id" />
  25. </div>
  26. </div>
  27. </Draggable>
  28. <div class="footer">
  29. <NcButton @click="showModal">
  30. <template #icon>
  31. <Pencil :size="20" />
  32. </template>
  33. {{ t('dashboard', 'Customize') }}
  34. </NcButton>
  35. </div>
  36. <NcModal v-if="modal" size="large" @close="closeModal">
  37. <div class="modal__content">
  38. <h3>{{ t('dashboard', 'Edit widgets') }}</h3>
  39. <ol class="panels">
  40. <li v-for="status in sortedAllStatuses" :key="status" :class="'panel-' + status">
  41. <input :id="'status-checkbox-' + status"
  42. type="checkbox"
  43. class="checkbox"
  44. :checked="isStatusActive(status)"
  45. @input="updateStatusCheckbox(status, $event.target.checked)">
  46. <label :for="'status-checkbox-' + status">
  47. <div :class="statusInfo[status].icon" role="img" />
  48. {{ statusInfo[status].text }}
  49. </label>
  50. </li>
  51. </ol>
  52. <Draggable v-model="layout"
  53. class="panels"
  54. tag="ol"
  55. v-bind="{swapThreshold: 0.30, delay: 500, delayOnTouchOnly: true, touchStartThreshold: 3}"
  56. handle=".draggable"
  57. @end="saveLayout">
  58. <li v-for="panel in sortedPanels" :key="panel.id" :class="'panel-' + panel.id">
  59. <input :id="'panel-checkbox-' + panel.id"
  60. type="checkbox"
  61. class="checkbox"
  62. :checked="isActive(panel)"
  63. @input="updateCheckbox(panel, $event.target.checked)">
  64. <label :for="'panel-checkbox-' + panel.id" :class="{ draggable: isActive(panel) }">
  65. <div :class="panel.iconClass" role="img" />
  66. {{ panel.title }}
  67. </label>
  68. </li>
  69. </Draggable>
  70. <a v-if="isAdmin" :href="appStoreUrl" class="button">{{ t('dashboard', 'Get more widgets from the App Store') }}</a>
  71. <h3>{{ t('dashboard', 'Weather service') }}</h3>
  72. <p>
  73. {{ 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.') }}
  74. </p>
  75. <p class="credits--end">
  76. <a href="https://api.met.no/doc/TermsOfService" target="_blank" rel="noopener">{{ t('dashboard', 'Weather data from Met.no') }}</a>,
  77. <a href="https://wiki.osmfoundation.org/wiki/Privacy_Policy" target="_blank" rel="noopener">{{ t('dashboard', 'geocoding with Nominatim') }}</a>,
  78. <a href="https://www.opentopodata.org/#public-api" target="_blank" rel="noopener">{{ t('dashboard', 'elevation data from OpenTopoData') }}</a>.
  79. </p>
  80. </div>
  81. </NcModal>
  82. </div>
  83. </template>
  84. <script>
  85. import { generateUrl } from '@nextcloud/router'
  86. import { getCurrentUser } from '@nextcloud/auth'
  87. import { loadState } from '@nextcloud/initial-state'
  88. import axios from '@nextcloud/axios'
  89. import NcButton from '@nextcloud/vue/dist/Components/NcButton'
  90. import Draggable from 'vuedraggable'
  91. import NcModal from '@nextcloud/vue/dist/Components/NcModal'
  92. import Pencil from 'vue-material-design-icons/Pencil.vue'
  93. import Vue from 'vue'
  94. import isMobile from './mixins/isMobile.js'
  95. const panels = loadState('dashboard', 'panels')
  96. const firstRun = loadState('dashboard', 'firstRun')
  97. const statusInfo = {
  98. weather: {
  99. text: t('dashboard', 'Weather'),
  100. icon: 'icon-weather-status',
  101. },
  102. status: {
  103. text: t('dashboard', 'Status'),
  104. icon: 'icon-user-status-online',
  105. },
  106. }
  107. export default {
  108. name: 'DashboardApp',
  109. components: {
  110. NcButton,
  111. Draggable,
  112. NcModal,
  113. Pencil,
  114. },
  115. mixins: [
  116. isMobile,
  117. ],
  118. data() {
  119. return {
  120. isAdmin: getCurrentUser().isAdmin,
  121. timer: new Date(),
  122. registeredStatus: [],
  123. callbacks: {},
  124. callbacksStatus: {},
  125. allCallbacksStatus: {},
  126. statusInfo,
  127. enabledStatuses: loadState('dashboard', 'statuses'),
  128. panels,
  129. firstRun,
  130. displayName: getCurrentUser()?.displayName,
  131. uid: getCurrentUser()?.uid,
  132. layout: loadState('dashboard', 'layout').filter((panelId) => panels[panelId]),
  133. modal: false,
  134. appStoreUrl: generateUrl('/settings/apps/dashboard'),
  135. statuses: {},
  136. }
  137. },
  138. computed: {
  139. greeting() {
  140. const time = this.timer.getHours()
  141. // Determine part of the day
  142. let partOfDay
  143. if (time >= 22 || time < 5) {
  144. partOfDay = 'night'
  145. } else if (time >= 18) {
  146. partOfDay = 'evening'
  147. } else if (time >= 12) {
  148. partOfDay = 'afternoon'
  149. } else {
  150. partOfDay = 'morning'
  151. }
  152. // Define the greetings
  153. const good = {
  154. morning: {
  155. generic: t('dashboard', 'Good morning'),
  156. withName: t('dashboard', 'Good morning, {name}', { name: this.displayName }, undefined, { escape: false }),
  157. },
  158. afternoon: {
  159. generic: t('dashboard', 'Good afternoon'),
  160. withName: t('dashboard', 'Good afternoon, {name}', { name: this.displayName }, undefined, { escape: false }),
  161. },
  162. evening: {
  163. generic: t('dashboard', 'Good evening'),
  164. withName: t('dashboard', 'Good evening, {name}', { name: this.displayName }, undefined, { escape: false }),
  165. },
  166. night: {
  167. // Don't use "Good night" as it's not a greeting
  168. generic: t('dashboard', 'Hello'),
  169. withName: t('dashboard', 'Hello, {name}', { name: this.displayName }, undefined, { escape: false }),
  170. },
  171. }
  172. // Figure out which greeting to show
  173. const shouldShowName = this.displayName && this.uid !== this.displayName
  174. return { text: shouldShowName ? good[partOfDay].withName : good[partOfDay].generic }
  175. },
  176. isActive() {
  177. return (panel) => this.layout.indexOf(panel.id) > -1
  178. },
  179. isStatusActive() {
  180. return (status) => !(status in this.enabledStatuses) || this.enabledStatuses[status]
  181. },
  182. sortedAllStatuses() {
  183. return Object.keys(this.allCallbacksStatus).slice().sort(this.sortStatuses)
  184. },
  185. sortedPanels() {
  186. return Object.values(this.panels).sort((a, b) => {
  187. const indexA = this.layout.indexOf(a.id)
  188. const indexB = this.layout.indexOf(b.id)
  189. if (indexA === -1 || indexB === -1) {
  190. return indexB - indexA || a.id - b.id
  191. }
  192. return indexA - indexB || a.id - b.id
  193. })
  194. },
  195. sortedRegisteredStatus() {
  196. return this.registeredStatus.slice().sort(this.sortStatuses)
  197. },
  198. },
  199. watch: {
  200. callbacks() {
  201. this.rerenderPanels()
  202. },
  203. callbacksStatus() {
  204. for (const app in this.callbacksStatus) {
  205. const element = this.$refs['status-' + app]
  206. if (this.statuses[app] && this.statuses[app].mounted) {
  207. continue
  208. }
  209. if (element) {
  210. this.callbacksStatus[app](element[0])
  211. Vue.set(this.statuses, app, { mounted: true })
  212. } else {
  213. console.error('Failed to register panel in the frontend as no backend data was provided for ' + app)
  214. }
  215. }
  216. },
  217. },
  218. mounted() {
  219. this.updateSkipLink()
  220. window.addEventListener('scroll', this.handleScroll)
  221. setInterval(() => {
  222. this.timer = new Date()
  223. }, 30000)
  224. if (this.firstRun) {
  225. window.addEventListener('scroll', this.disableFirstrunHint)
  226. }
  227. },
  228. destroyed() {
  229. window.removeEventListener('scroll', this.handleScroll)
  230. },
  231. methods: {
  232. /**
  233. * Method to register panels that will be called by the integrating apps
  234. *
  235. * @param {string} app The unique app id for the widget
  236. * @param {Function} callback The callback function to register a panel which gets the DOM element passed as parameter
  237. */
  238. register(app, callback) {
  239. Vue.set(this.callbacks, app, callback)
  240. },
  241. registerStatus(app, callback) {
  242. // always save callbacks in case user enables the status later
  243. Vue.set(this.allCallbacksStatus, app, callback)
  244. // register only if status is enabled or missing from config
  245. if (this.isStatusActive(app)) {
  246. this.registeredStatus.push(app)
  247. this.$nextTick(() => {
  248. Vue.set(this.callbacksStatus, app, callback)
  249. })
  250. }
  251. },
  252. rerenderPanels() {
  253. for (const app in this.callbacks) {
  254. const element = this.$refs[app]
  255. if (this.layout.indexOf(app) === -1) {
  256. continue
  257. }
  258. if (this.panels[app] && this.panels[app].mounted) {
  259. continue
  260. }
  261. if (element) {
  262. this.callbacks[app](element[0], {
  263. widget: this.panels[app],
  264. })
  265. Vue.set(this.panels[app], 'mounted', true)
  266. } else {
  267. console.error('Failed to register panel in the frontend as no backend data was provided for ' + app)
  268. }
  269. }
  270. },
  271. saveLayout() {
  272. axios.post(generateUrl('/apps/dashboard/layout'), {
  273. layout: this.layout.join(','),
  274. })
  275. },
  276. saveStatuses() {
  277. axios.post(generateUrl('/apps/dashboard/statuses'), {
  278. statuses: JSON.stringify(this.enabledStatuses),
  279. })
  280. },
  281. showModal() {
  282. this.modal = true
  283. this.firstRun = false
  284. },
  285. closeModal() {
  286. this.modal = false
  287. },
  288. updateCheckbox(panel, currentValue) {
  289. const index = this.layout.indexOf(panel.id)
  290. if (!currentValue && index > -1) {
  291. this.layout.splice(index, 1)
  292. } else {
  293. this.layout.push(panel.id)
  294. }
  295. Vue.set(this.panels[panel.id], 'mounted', false)
  296. this.saveLayout()
  297. this.$nextTick(() => this.rerenderPanels())
  298. },
  299. disableFirstrunHint() {
  300. window.removeEventListener('scroll', this.disableFirstrunHint)
  301. setTimeout(() => {
  302. this.firstRun = false
  303. }, 1000)
  304. },
  305. updateSkipLink() {
  306. // Make sure "Skip to main content" link points to the app content
  307. document.getElementsByClassName('skip-navigation')[0].setAttribute('href', '#app-dashboard')
  308. },
  309. updateStatusCheckbox(app, checked) {
  310. if (checked) {
  311. this.enableStatus(app)
  312. } else {
  313. this.disableStatus(app)
  314. }
  315. },
  316. enableStatus(app) {
  317. this.enabledStatuses[app] = true
  318. this.registerStatus(app, this.allCallbacksStatus[app])
  319. this.saveStatuses()
  320. },
  321. disableStatus(app) {
  322. this.enabledStatuses[app] = false
  323. const i = this.registeredStatus.findIndex((s) => s === app)
  324. if (i !== -1) {
  325. this.registeredStatus.splice(i, 1)
  326. Vue.set(this.statuses, app, { mounted: false })
  327. this.$nextTick(() => {
  328. Vue.delete(this.callbacksStatus, app)
  329. })
  330. }
  331. this.saveStatuses()
  332. },
  333. sortStatuses(a, b) {
  334. const al = a.toLowerCase()
  335. const bl = b.toLowerCase()
  336. return al > bl
  337. ? 1
  338. : al < bl
  339. ? -1
  340. : 0
  341. },
  342. handleScroll() {
  343. if (window.scrollY > 70) {
  344. document.body.classList.add('dashboard--scrolled')
  345. } else {
  346. document.body.classList.remove('dashboard--scrolled')
  347. }
  348. },
  349. },
  350. }
  351. </script>
  352. <style lang="scss" scoped>
  353. #app-dashboard {
  354. width: 100%;
  355. min-height: 100%;
  356. background-size: cover;
  357. background-position: center center;
  358. background-repeat: no-repeat;
  359. background-attachment: fixed;
  360. > h2 {
  361. color: var(--color-primary-text);
  362. text-align: center;
  363. font-size: 32px;
  364. line-height: 130%;
  365. padding: 1rem 0;
  366. }
  367. }
  368. .panels {
  369. width: auto;
  370. margin: auto;
  371. max-width: 1800px;
  372. display: flex;
  373. justify-content: center;
  374. flex-direction: row;
  375. align-items: flex-start;
  376. flex-wrap: wrap;
  377. }
  378. .panel, .panels > div {
  379. width: 320px;
  380. max-width: 100%;
  381. margin: 16px;
  382. align-self: stretch;
  383. background-color: var(--color-main-background-blur);
  384. -webkit-backdrop-filter: var(--filter-background-blur);
  385. backdrop-filter: var(--filter-background-blur);
  386. border-radius: var(--border-radius-large);
  387. #body-user.theme--highcontrast & {
  388. border: 2px solid var(--color-border);
  389. }
  390. &.sortable-ghost {
  391. opacity: 0.1;
  392. }
  393. & > .panel--header {
  394. display: flex;
  395. z-index: 1;
  396. top: 50px;
  397. padding: 16px;
  398. cursor: grab;
  399. &, ::v-deep * {
  400. -webkit-touch-callout: none;
  401. -webkit-user-select: none;
  402. -khtml-user-select: none;
  403. -moz-user-select: none;
  404. -ms-user-select: none;
  405. user-select: none;
  406. }
  407. &:active {
  408. cursor: grabbing;
  409. }
  410. a {
  411. flex-grow: 1;
  412. }
  413. > h2 {
  414. display: block;
  415. align-items: center;
  416. flex-grow: 1;
  417. margin: 0;
  418. font-size: 20px;
  419. line-height: 24px;
  420. font-weight: bold;
  421. padding: 16px 8px;
  422. height: 56px;
  423. white-space: nowrap;
  424. overflow: hidden;
  425. text-overflow: ellipsis;
  426. cursor: grab;
  427. div {
  428. background-size: 32px;
  429. width: 32px;
  430. height: 32px;
  431. margin-right: 16px;
  432. background-position: center;
  433. float: left;
  434. }
  435. }
  436. }
  437. & > .panel--content {
  438. margin: 0 16px 16px 16px;
  439. height: 424px;
  440. // We specifically do not want scrollbars inside widgets
  441. overflow: visible;
  442. }
  443. // No need to extend height of widgets if only one column is shown
  444. @media only screen and (max-width: 709px) {
  445. & > .panel--content {
  446. height: auto;
  447. }
  448. }
  449. }
  450. .footer {
  451. display: flex;
  452. justify-content: center;
  453. transition: bottom var(--animation-slow) ease-in-out;
  454. padding: 1rem 0;
  455. }
  456. .edit-panels {
  457. display: inline-block;
  458. margin:auto;
  459. background-position: 16px center;
  460. padding: 12px 16px;
  461. padding-left: 36px;
  462. border-radius: var(--border-radius-pill);
  463. max-width: 200px;
  464. opacity: 1;
  465. text-align: center;
  466. }
  467. .button,
  468. .button-vue,
  469. .edit-panels,
  470. .statuses ::v-deep .action-item .action-item__menutoggle,
  471. .statuses ::v-deep .action-item.action-item--open .action-item__menutoggle {
  472. background-color: var(--color-main-background-blur);
  473. -webkit-backdrop-filter: var(--filter-background-blur);
  474. backdrop-filter: var(--filter-background-blur);
  475. opacity: 1 !important;
  476. &:hover,
  477. &:focus,
  478. &:active {
  479. background-color: var(--color-background-hover)!important;
  480. }
  481. &:focus-visible {
  482. box-shadow: 0 0 0 2px var(--color-main-text) !important;
  483. }
  484. }
  485. .modal__content {
  486. padding: 32px 16px;
  487. text-align: center;
  488. ol {
  489. display: flex;
  490. flex-direction: row;
  491. justify-content: center;
  492. list-style-type: none;
  493. padding-bottom: 16px;
  494. }
  495. li {
  496. label {
  497. position: relative;
  498. display: block;
  499. padding: 48px 16px 14px 16px;
  500. margin: 8px;
  501. width: 140px;
  502. background-color: var(--color-background-hover);
  503. border: 2px solid var(--color-main-background);
  504. border-radius: var(--border-radius-large);
  505. text-align: left;
  506. overflow: hidden;
  507. text-overflow: ellipsis;
  508. white-space: nowrap;
  509. div {
  510. position: absolute;
  511. top: 16px;
  512. width: 24px;
  513. height: 24px;
  514. background-size: 24px;
  515. }
  516. &:hover {
  517. border-color: var(--color-primary);
  518. }
  519. }
  520. // Do not invert status icons
  521. &:not(.panel-status) label div {
  522. filter: var(--background-invert-if-dark);
  523. }
  524. input[type='checkbox'].checkbox + label:before {
  525. position: absolute;
  526. right: 12px;
  527. top: 16px;
  528. }
  529. input:focus + label {
  530. border-color: var(--color-primary);
  531. }
  532. }
  533. h3 {
  534. font-weight: bold;
  535. &:not(:first-of-type) {
  536. margin-top: 64px;
  537. }
  538. }
  539. // Adjust design of 'Get more widgets' button
  540. .button {
  541. display: inline-block;
  542. padding: 10px 16px;
  543. margin: 0;
  544. }
  545. p {
  546. max-width: 650px;
  547. margin: 0 auto;
  548. a:hover,
  549. a:focus {
  550. border-bottom: 2px solid var(--color-border);
  551. }
  552. }
  553. .credits--end {
  554. padding-bottom: 32px;
  555. color: var(--color-text-maxcontrast);
  556. a {
  557. color: var(--color-text-maxcontrast);
  558. }
  559. }
  560. }
  561. .flip-list-move {
  562. transition: transform var(--animation-slow);
  563. }
  564. .statuses {
  565. display: flex;
  566. flex-direction: row;
  567. justify-content: center;
  568. flex-wrap: wrap;
  569. margin-bottom: 36px;
  570. & > div {
  571. margin: 8px;
  572. }
  573. }
  574. </style>
  575. <style>
  576. html, body {
  577. background-attachment: fixed;
  578. }
  579. #body-user #header {
  580. position: fixed;
  581. }
  582. #content {
  583. overflow: auto;
  584. }
  585. </style>