BackgroundSettings.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380
  1. <!--
  2. - @copyright Copyright (c) 2020 Julius Härtl <jus@bitgrid.net>
  3. -
  4. - @author Christopher Ng <chrng8@gmail.com>
  5. - @author Greta Doci <gretadoci@gmail.com>
  6. - @author John Molakvoæ <skjnldsv@protonmail.com>
  7. - @author Julius Härtl <jus@bitgrid.net>
  8. -
  9. - @license GNU AGPL version 3 or any later version
  10. -
  11. - This program is free software: you can redistribute it and/or modify
  12. - it under the terms of the GNU Affero General Public License as
  13. - published by the Free Software Foundation, either version 3 of the
  14. - License, or (at your option) any later version.
  15. -
  16. - This program is distributed in the hope that it will be useful,
  17. - but WITHOUT ANY WARRANTY; without even the implied warranty of
  18. - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  19. - GNU Affero General Public License for more details.
  20. -
  21. - You should have received a copy of the GNU Affero General Public License
  22. - along with this program. If not, see <http://www.gnu.org/licenses/>.
  23. -
  24. -->
  25. <template>
  26. <div class="background-selector" data-user-theming-background-settings>
  27. <!-- Custom background -->
  28. <button class="background background__filepicker"
  29. :class="{ 'icon-loading': loading === 'custom', 'background--active': backgroundImage === 'custom' }"
  30. :data-color-bright="invertTextColor(Theming.color)"
  31. data-user-theming-background-custom
  32. tabindex="0"
  33. @click="pickFile">
  34. {{ t('theming', 'Custom background') }}
  35. <ImageEdit v-if="backgroundImage !== 'custom'" :size="26" />
  36. <Check :size="44" />
  37. </button>
  38. <!-- Default background -->
  39. <button class="background background__default"
  40. :class="{ 'icon-loading': loading === 'default', 'background--active': backgroundImage === 'default' }"
  41. :data-color-bright="invertTextColor(Theming.defaultColor)"
  42. :style="{ '--border-color': Theming.defaultColor }"
  43. data-user-theming-background-default
  44. tabindex="0"
  45. @click="setDefault">
  46. {{ t('theming', 'Default background') }}
  47. <Check :size="44" />
  48. </button>
  49. <!-- Custom color picker -->
  50. <NcColorPicker v-model="Theming.color" @input="debouncePickColor">
  51. <button class="background background__color"
  52. :data-color="Theming.color"
  53. :data-color-bright="invertTextColor(Theming.color)"
  54. :style="{ backgroundColor: Theming.color, '--border-color': Theming.color}"
  55. data-user-theming-background-color
  56. tabindex="0">
  57. {{ t('theming', 'Change color') }}
  58. </button>
  59. </NcColorPicker>
  60. <!-- Remove background -->
  61. <button class="background background__delete"
  62. :class="{ 'background--active': isBackgroundDisabled }"
  63. data-user-theming-background-clear
  64. tabindex="0"
  65. @click="removeBackground">
  66. {{ t('theming', 'No background') }}
  67. <Close v-if="!isBackgroundDisabled" :size="32" />
  68. <Check :size="44" />
  69. </button>
  70. <!-- Background set selection -->
  71. <button v-for="shippedBackground in shippedBackgrounds"
  72. :key="shippedBackground.name"
  73. :title="shippedBackground.details.attribution"
  74. :aria-label="shippedBackground.details.attribution"
  75. :class="{ 'icon-loading': loading === shippedBackground.name, 'background--active': backgroundImage === shippedBackground.name }"
  76. :data-color-bright="shippedBackground.details.theming === 'dark'"
  77. :data-user-theming-background-shipped="shippedBackground.name"
  78. :style="{ backgroundImage: 'url(' + shippedBackground.preview + ')', '--border-color': shippedBackground.details.primary_color }"
  79. class="background background__shipped"
  80. tabindex="0"
  81. @click="setShipped(shippedBackground.name)">
  82. <Check :size="44" />
  83. </button>
  84. </div>
  85. </template>
  86. <script>
  87. import { generateFilePath, generateRemoteUrl, generateUrl } from '@nextcloud/router'
  88. import { loadState } from '@nextcloud/initial-state'
  89. import axios from '@nextcloud/axios'
  90. import Check from 'vue-material-design-icons/Check.vue'
  91. import Close from 'vue-material-design-icons/Close.vue'
  92. import ImageEdit from 'vue-material-design-icons/ImageEdit.vue'
  93. import debounce from 'debounce'
  94. import NcColorPicker from '@nextcloud/vue/dist/Components/NcColorPicker.js'
  95. import Vibrant from 'node-vibrant'
  96. import { Palette } from 'node-vibrant/lib/color.js'
  97. import { getFilePickerBuilder } from '@nextcloud/dialogs'
  98. import { getCurrentUser } from '@nextcloud/auth'
  99. const backgroundImage = loadState('theming', 'backgroundImage')
  100. const shippedBackgroundList = loadState('theming', 'shippedBackgrounds')
  101. const themingDefaultBackground = loadState('theming', 'themingDefaultBackground')
  102. const defaultShippedBackground = loadState('theming', 'defaultShippedBackground')
  103. const prefixWithBaseUrl = (url) => generateFilePath('theming', '', 'img/background/') + url
  104. const picker = getFilePickerBuilder(t('theming', 'Select a background from your files'))
  105. .setMultiSelect(false)
  106. .setModal(true)
  107. .setType(1)
  108. .setMimeTypeFilter(['image/png', 'image/gif', 'image/jpeg', 'image/svg+xml', 'image/svg'])
  109. .build()
  110. export default {
  111. name: 'BackgroundSettings',
  112. components: {
  113. Check,
  114. Close,
  115. ImageEdit,
  116. NcColorPicker,
  117. },
  118. data() {
  119. return {
  120. loading: false,
  121. Theming: loadState('theming', 'data', {}),
  122. // User background image and color settings
  123. backgroundImage,
  124. }
  125. },
  126. computed: {
  127. shippedBackgrounds() {
  128. return Object.keys(shippedBackgroundList)
  129. .map(fileName => {
  130. return {
  131. name: fileName,
  132. url: prefixWithBaseUrl(fileName),
  133. preview: prefixWithBaseUrl('preview/' + fileName),
  134. details: shippedBackgroundList[fileName],
  135. }
  136. })
  137. .filter(background => {
  138. // If the admin did not changed the global background
  139. // let's hide the default background to not show it twice
  140. if (!this.isGlobalBackgroundDeleted && !this.isGlobalBackgroundDefault) {
  141. return background.name !== defaultShippedBackground
  142. }
  143. return true
  144. })
  145. },
  146. isGlobalBackgroundDefault() {
  147. return !!themingDefaultBackground
  148. },
  149. isGlobalBackgroundDeleted() {
  150. return themingDefaultBackground === 'backgroundColor'
  151. },
  152. isBackgroundDisabled() {
  153. return this.backgroundImage === 'disabled'
  154. || !this.backgroundImage
  155. },
  156. },
  157. methods: {
  158. /**
  159. * Do we need to invert the text if color is too bright?
  160. *
  161. * @param {string} color the hex color
  162. */
  163. invertTextColor(color) {
  164. return this.calculateLuma(color) > 0.6
  165. },
  166. /**
  167. * Calculate luminance of provided hex color
  168. *
  169. * @param {string} color the hex color
  170. */
  171. calculateLuma(color) {
  172. const [red, green, blue] = this.hexToRGB(color)
  173. return (0.2126 * red + 0.7152 * green + 0.0722 * blue) / 255
  174. },
  175. /**
  176. * Convert hex color to RGB
  177. *
  178. * @param {string} hex the hex color
  179. */
  180. hexToRGB(hex) {
  181. const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
  182. return result
  183. ? [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16)]
  184. : null
  185. },
  186. /**
  187. * Update local state
  188. *
  189. * @param {object} data destructuring object
  190. * @param {string} data.backgroundColor background color value
  191. * @param {string} data.backgroundImage background image value
  192. * @param {string} data.version cache buster number
  193. * @see https://github.com/nextcloud/server/blob/c78bd45c64d9695724fc44fe8453a88824b85f2f/apps/theming/lib/Controller/UserThemeController.php#L187-L191
  194. */
  195. async update(data) {
  196. // Update state
  197. this.backgroundImage = data.backgroundImage
  198. this.Theming.color = data.backgroundColor
  199. // Notify parent and reload style
  200. this.$emit('update:background')
  201. this.loading = false
  202. },
  203. async setDefault() {
  204. this.loading = 'default'
  205. const result = await axios.post(generateUrl('/apps/theming/background/default'))
  206. this.update(result.data)
  207. },
  208. async setShipped(shipped) {
  209. this.loading = shipped
  210. const result = await axios.post(generateUrl('/apps/theming/background/shipped'), { value: shipped })
  211. this.update(result.data)
  212. },
  213. async setFile(path, color = null) {
  214. this.loading = 'custom'
  215. const result = await axios.post(generateUrl('/apps/theming/background/custom'), { value: path, color })
  216. this.update(result.data)
  217. },
  218. async removeBackground() {
  219. this.loading = 'remove'
  220. const result = await axios.delete(generateUrl('/apps/theming/background/custom'))
  221. this.update(result.data)
  222. },
  223. async pickColor(event) {
  224. this.loading = 'color'
  225. const color = event?.target?.dataset?.color || this.Theming?.color || '#0082c9'
  226. const result = await axios.post(generateUrl('/apps/theming/background/color'), { color })
  227. this.update(result.data)
  228. },
  229. debouncePickColor: debounce(function(...args) {
  230. this.pickColor(...args)
  231. }, 200),
  232. async pickFile() {
  233. const path = await picker.pick()
  234. this.loading = 'custom'
  235. // Extract primary color from image
  236. let response = null
  237. let color = null
  238. try {
  239. const fileUrl = generateRemoteUrl('dav/files/' + getCurrentUser().uid + path)
  240. response = await axios.get(fileUrl, { responseType: 'blob' })
  241. const blobUrl = URL.createObjectURL(response.data)
  242. const palette = await this.getColorPaletteFromBlob(blobUrl)
  243. // DarkVibrant is accessible AND visually pleasing
  244. // Vibrant is not accessible enough and others are boring
  245. color = palette?.DarkVibrant?.hex
  246. this.setFile(path, color)
  247. // Log data
  248. console.debug('Extracted colour', color, 'from custom image', path, palette)
  249. } catch (error) {
  250. this.setFile(path)
  251. console.error('Unable to extract colour from custom image', { error, path, response, color })
  252. }
  253. },
  254. /**
  255. * Extract a Vibrant color palette from a blob URL
  256. *
  257. * @param {string} blobUrl the blob URL
  258. * @return {Promise<Palette>}
  259. */
  260. getColorPaletteFromBlob(blobUrl) {
  261. return new Promise((resolve, reject) => {
  262. const vibrant = new Vibrant(blobUrl)
  263. vibrant.getPalette((error, palette) => {
  264. if (error) {
  265. reject(error)
  266. }
  267. resolve(palette)
  268. })
  269. })
  270. },
  271. },
  272. }
  273. </script>
  274. <style scoped lang="scss">
  275. .background-selector {
  276. display: flex;
  277. flex-wrap: wrap;
  278. justify-content: center;
  279. .background {
  280. overflow: hidden;
  281. width: 176px;
  282. height: 96px;
  283. margin: 8px;
  284. text-align: center;
  285. border: 2px solid var(--color-main-background);
  286. border-radius: var(--border-radius-large);
  287. background-position: center center;
  288. background-size: cover;
  289. &__filepicker {
  290. &.background--active {
  291. color: white;
  292. background-image: var(--image-background);
  293. }
  294. }
  295. &__default {
  296. background-color: var(--color-primary-default);
  297. background-image: var(--image-background-plain, var(--image-background-default));
  298. }
  299. &__filepicker, &__default, &__color {
  300. border-color: var(--color-border);
  301. }
  302. &__color {
  303. color: var(--color-primary-text);
  304. background-color: var(--color-primary-default);
  305. }
  306. // Over a background image
  307. &__default,
  308. &__shipped {
  309. color: white;
  310. }
  311. // Text and svg icon dark on bright background
  312. &[data-color-bright] {
  313. color: black;
  314. }
  315. &--active,
  316. &:hover,
  317. &:focus {
  318. // Use theme color primary, see inline css variable in template
  319. border: 2px solid var(--border-color, var(--color-primary-element)) !important;
  320. }
  321. // Icon
  322. span {
  323. margin: 4px;
  324. }
  325. .check-icon {
  326. display: none;
  327. }
  328. &--active:not(.icon-loading) {
  329. .check-icon {
  330. // Show checkmark
  331. display: block !important;
  332. }
  333. }
  334. }
  335. }
  336. </style>