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 12KB

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