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.

BackgroundSettings.vue 11KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375
  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. <Check :size="44" />
  36. </button>
  37. <!-- Default background -->
  38. <button class="background background__default"
  39. :class="{ 'icon-loading': loading === 'default', 'background--active': backgroundImage === 'default' }"
  40. :data-color-bright="invertTextColor(Theming.defaultColor)"
  41. :style="{ '--border-color': Theming.defaultColor }"
  42. data-user-theming-background-default
  43. tabindex="0"
  44. @click="setDefault">
  45. {{ t('theming', 'Default background') }}
  46. <Check :size="44" />
  47. </button>
  48. <!-- Custom color picker -->
  49. <NcColorPicker v-model="Theming.color" @input="debouncePickColor">
  50. <button class="background background__color"
  51. :data-color="Theming.color"
  52. :data-color-bright="invertTextColor(Theming.color)"
  53. :style="{ backgroundColor: Theming.color, '--border-color': Theming.color}"
  54. data-user-theming-background-color
  55. tabindex="0">
  56. {{ t('theming', 'Change color') }}
  57. </button>
  58. </NcColorPicker>
  59. <!-- Background set selection -->
  60. <button v-for="shippedBackground in shippedBackgrounds"
  61. :key="shippedBackground.name"
  62. v-tooltip="shippedBackground.details.attribution"
  63. :class="{ 'icon-loading': loading === shippedBackground.name, 'background--active': backgroundImage === shippedBackground.name }"
  64. :data-color-bright="shippedBackground.details.theming === 'dark'"
  65. :data-user-theming-background-shipped="shippedBackground.name"
  66. :style="{ backgroundImage: 'url(' + shippedBackground.preview + ')', '--border-color': shippedBackground.details.primary_color }"
  67. class="background background__shipped"
  68. tabindex="0"
  69. @click="setShipped(shippedBackground.name)">
  70. <Check :size="44" />
  71. </button>
  72. <!-- Remove background -->
  73. <button class="background background__delete"
  74. data-user-theming-background-clear
  75. tabindex="0"
  76. @click="removeBackground">
  77. {{ t('theming', 'Remove background') }}
  78. <Close :size="32" />
  79. </button>
  80. </div>
  81. </template>
  82. <script>
  83. import { generateFilePath, generateRemoteUrl, generateUrl } from '@nextcloud/router'
  84. import { loadState } from '@nextcloud/initial-state'
  85. import axios from '@nextcloud/axios'
  86. import Check from 'vue-material-design-icons/Check.vue'
  87. import Close from 'vue-material-design-icons/Close.vue'
  88. import debounce from 'debounce'
  89. import NcColorPicker from '@nextcloud/vue/dist/Components/NcColorPicker'
  90. import Tooltip from '@nextcloud/vue/dist/Directives/Tooltip'
  91. import Vibrant from 'node-vibrant'
  92. import { Palette } from 'node-vibrant/lib/color'
  93. import { getFilePickerBuilder } from '@nextcloud/dialogs'
  94. import { getCurrentUser } from '@nextcloud/auth'
  95. const backgroundColor = loadState('theming', 'backgroundColor')
  96. const backgroundImage = loadState('theming', 'backgroundImage')
  97. const shippedBackgroundList = loadState('theming', 'shippedBackgrounds')
  98. const themingDefaultBackground = loadState('theming', 'themingDefaultBackground')
  99. const defaultShippedBackground = loadState('theming', 'defaultShippedBackground')
  100. const prefixWithBaseUrl = (url) => generateFilePath('theming', '', 'img/background/') + url
  101. const picker = getFilePickerBuilder(t('theming', 'Select a background from your files'))
  102. .setMultiSelect(false)
  103. .setModal(true)
  104. .setType(1)
  105. .setMimeTypeFilter(['image/png', 'image/gif', 'image/jpeg', 'image/svg+xml', 'image/svg'])
  106. .build()
  107. export default {
  108. name: 'BackgroundSettings',
  109. directives: {
  110. Tooltip,
  111. },
  112. components: {
  113. Check,
  114. Close,
  115. NcColorPicker,
  116. },
  117. data() {
  118. return {
  119. loading: false,
  120. Theming: loadState('theming', 'data', {}),
  121. // User background image and color settings
  122. backgroundImage,
  123. backgroundColor,
  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. },
  153. methods: {
  154. /**
  155. * Do we need to invert the text if color is too bright?
  156. *
  157. * @param {string} color the hex color
  158. */
  159. invertTextColor(color) {
  160. return this.calculateLuma(color) > 0.6
  161. },
  162. /**
  163. * Calculate luminance of provided hex color
  164. *
  165. * @param {string} color the hex color
  166. */
  167. calculateLuma(color) {
  168. const [red, green, blue] = this.hexToRGB(color)
  169. return (0.2126 * red + 0.7152 * green + 0.0722 * blue) / 255
  170. },
  171. /**
  172. * Convert hex color to RGB
  173. *
  174. * @param {string} hex the hex color
  175. */
  176. hexToRGB(hex) {
  177. const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
  178. return result
  179. ? [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16)]
  180. : null
  181. },
  182. /**
  183. * Update local state
  184. *
  185. * @param {object} data destructuring object
  186. * @param {string} data.backgroundColor background color value
  187. * @param {string} data.backgroundImage background image value
  188. * @param {string} data.version cache buster number
  189. * @see https://github.com/nextcloud/server/blob/c78bd45c64d9695724fc44fe8453a88824b85f2f/apps/theming/lib/Controller/UserThemeController.php#L187-L191
  190. */
  191. async update(data) {
  192. // Update state
  193. this.backgroundImage = data.backgroundImage
  194. this.backgroundColor = data.backgroundColor
  195. this.Theming.color = data.backgroundColor
  196. // Notify parent and reload style
  197. this.$emit('update:background')
  198. this.loading = false
  199. },
  200. async setDefault() {
  201. this.loading = 'default'
  202. const result = await axios.post(generateUrl('/apps/theming/background/default'))
  203. this.update(result.data)
  204. },
  205. async setShipped(shipped) {
  206. this.loading = shipped
  207. const result = await axios.post(generateUrl('/apps/theming/background/shipped'), { value: shipped })
  208. this.update(result.data)
  209. },
  210. async setFile(path, color = null) {
  211. this.loading = 'custom'
  212. const result = await axios.post(generateUrl('/apps/theming/background/custom'), { value: path, color })
  213. this.update(result.data)
  214. },
  215. async removeBackground() {
  216. this.loading = 'remove'
  217. const result = await axios.delete(generateUrl('/apps/theming/background/custom'))
  218. this.update(result.data)
  219. },
  220. async pickColor(event) {
  221. this.loading = 'color'
  222. const color = event?.target?.dataset?.color || this.Theming?.color || '#0082c9'
  223. const result = await axios.post(generateUrl('/apps/theming/background/color'), { color })
  224. this.update(result.data)
  225. },
  226. debouncePickColor: debounce(function() {
  227. this.pickColor(...arguments)
  228. }, 200),
  229. async pickFile() {
  230. const path = await picker.pick()
  231. this.loading = 'custom'
  232. // Extract primary color from image
  233. let response = null
  234. let color = null
  235. try {
  236. const fileUrl = generateRemoteUrl('dav/files/' + getCurrentUser().uid + path)
  237. response = await axios.get(fileUrl, { responseType: 'blob' })
  238. const blobUrl = URL.createObjectURL(response.data)
  239. const palette = await this.getColorPaletteFromBlob(blobUrl)
  240. // DarkVibrant is accessible AND visually pleasing
  241. // Vibrant is not accessible enough and others are boring
  242. color = palette?.DarkVibrant?.hex
  243. this.setFile(path, color)
  244. // Log data
  245. console.debug('Extracted colour', color, 'from custom image', path, palette)
  246. } catch (error) {
  247. this.setFile(path)
  248. console.error('Unable to extract colour from custom image', { error, path, response, color })
  249. }
  250. },
  251. /**
  252. * Extract a Vibrant color palette from a blob URL
  253. *
  254. * @param {string} blobUrl the blob URL
  255. * @return {Promise<Palette>}
  256. */
  257. getColorPaletteFromBlob(blobUrl) {
  258. return new Promise((resolve, reject) => {
  259. const vibrant = new Vibrant(blobUrl)
  260. vibrant.getPalette((error, palette) => {
  261. if (error) {
  262. reject(error)
  263. }
  264. resolve(palette)
  265. })
  266. })
  267. },
  268. },
  269. }
  270. </script>
  271. <style scoped lang="scss">
  272. .background-selector {
  273. display: flex;
  274. flex-wrap: wrap;
  275. justify-content: center;
  276. .background {
  277. overflow: hidden;
  278. width: 176px;
  279. height: 96px;
  280. margin: 8px;
  281. text-align: center;
  282. border: 2px solid var(--color-main-background);
  283. border-radius: var(--border-radius-large);
  284. background-position: center center;
  285. background-size: cover;
  286. &__filepicker {
  287. &.background--active {
  288. color: white;
  289. background-image: var(--image-background);
  290. }
  291. }
  292. &__default {
  293. background-color: var(--color-primary-default);
  294. background-image: var(--image-background-default);
  295. }
  296. &__filepicker, &__default, &__color {
  297. border-color: var(--color-border);
  298. }
  299. &__color {
  300. color: var(--color-primary-text);
  301. background-color: var(--color-primary-default);
  302. }
  303. // Over a background image
  304. &__default,
  305. &__shipped {
  306. color: white;
  307. }
  308. // Text and svg icon dark on bright background
  309. &[data-color-bright] {
  310. color: black;
  311. }
  312. &--active,
  313. &:hover,
  314. &:focus {
  315. // Use theme color primary, see inline css variable in template
  316. border: 2px solid var(--border-color, var(--color-primary)) !important;
  317. }
  318. // Icon
  319. span {
  320. margin: 4px;
  321. }
  322. &__filepicker span,
  323. &__default span,
  324. &__shipped span {
  325. display: none;
  326. }
  327. &--active:not(.icon-loading) span {
  328. display: block !important;
  329. }
  330. }
  331. }
  332. </style>