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.
395 lines
11 KiB
395 lines
11 KiB
<!--
|
|
- SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
|
|
- SPDX-License-Identifier: AGPL-3.0-or-later
|
|
-->
|
|
|
|
<template>
|
|
<div class="background-selector" data-user-theming-background-settings>
|
|
<!-- Custom background -->
|
|
<button :aria-pressed="backgroundImage === 'custom'"
|
|
:class="{
|
|
'icon-loading': loading === 'custom',
|
|
'background background__filepicker': true,
|
|
'background--active': backgroundImage === 'custom'
|
|
}"
|
|
data-user-theming-background-custom
|
|
tabindex="0"
|
|
@click="pickFile">
|
|
{{ t('theming', 'Custom background') }}
|
|
<ImageEdit v-if="backgroundImage !== 'custom'" :size="20" />
|
|
<Check :size="44" />
|
|
</button>
|
|
|
|
<!-- Custom color picker -->
|
|
<NcColorPicker v-model="Theming.backgroundColor" @update:value="debouncePickColor">
|
|
<button :class="{
|
|
'icon-loading': loading === 'color',
|
|
'background background__color': true,
|
|
'background--active': backgroundImage === 'color'
|
|
}"
|
|
:aria-pressed="backgroundImage === 'color'"
|
|
:data-color="Theming.backgroundColor"
|
|
:data-color-bright="invertTextColor(Theming.backgroundColor)"
|
|
:style="{ backgroundColor: Theming.backgroundColor, '--border-color': Theming.backgroundColor}"
|
|
data-user-theming-background-color
|
|
tabindex="0"
|
|
@click="backgroundImage !== 'color' && debouncePickColor(Theming.backgroundColor)">
|
|
{{ t('theming', 'Plain background') /* TRANSLATORS: Background using a single color */ }}
|
|
<ColorPalette v-if="backgroundImage !== 'color'" :size="20" />
|
|
<Check :size="44" />
|
|
</button>
|
|
</NcColorPicker>
|
|
|
|
<!-- Default background -->
|
|
<button :aria-pressed="backgroundImage === 'default'"
|
|
:class="{
|
|
'icon-loading': loading === 'default',
|
|
'background background__default': true,
|
|
'background--active': backgroundImage === 'default'
|
|
}"
|
|
:data-color-bright="invertTextColor(Theming.defaultBackgroundColor)"
|
|
:style="{ '--border-color': Theming.defaultBackgroundColor }"
|
|
data-user-theming-background-default
|
|
tabindex="0"
|
|
@click="setDefault">
|
|
{{ t('theming', 'Default background') }}
|
|
<Check :size="44" />
|
|
</button>
|
|
|
|
<!-- Background set selection -->
|
|
<button v-for="shippedBackground in shippedBackgrounds"
|
|
:key="shippedBackground.name"
|
|
:title="shippedBackground.details.attribution"
|
|
:aria-label="shippedBackground.details.description"
|
|
:aria-pressed="backgroundImage === shippedBackground.name"
|
|
:class="{
|
|
'background background__shipped': true,
|
|
'icon-loading': loading === shippedBackground.name,
|
|
'background--active': backgroundImage === shippedBackground.name
|
|
}"
|
|
:data-color-bright="invertTextColor(shippedBackground.details.background_color)"
|
|
:data-user-theming-background-shipped="shippedBackground.name"
|
|
:style="{ backgroundImage: 'url(' + shippedBackground.preview + ')', '--border-color': shippedBackground.details.primary_color }"
|
|
tabindex="0"
|
|
@click="setShipped(shippedBackground.name)">
|
|
<Check :size="44" />
|
|
</button>
|
|
</div>
|
|
</template>
|
|
|
|
<script>
|
|
import { generateFilePath, generateRemoteUrl, generateUrl } from '@nextcloud/router'
|
|
import { getCurrentUser } from '@nextcloud/auth'
|
|
import { getFilePickerBuilder, showError } from '@nextcloud/dialogs'
|
|
import { loadState } from '@nextcloud/initial-state'
|
|
import { Palette } from 'node-vibrant/lib/color.js'
|
|
import axios from '@nextcloud/axios'
|
|
import debounce from 'debounce'
|
|
import NcColorPicker from '@nextcloud/vue/dist/Components/NcColorPicker.js'
|
|
import Vibrant from 'node-vibrant'
|
|
|
|
import Check from 'vue-material-design-icons/Check.vue'
|
|
import ImageEdit from 'vue-material-design-icons/ImageEdit.vue'
|
|
import ColorPalette from 'vue-material-design-icons/Palette.vue'
|
|
|
|
const shippedBackgroundList = loadState('theming', 'shippedBackgrounds')
|
|
const backgroundImage = loadState('theming', 'userBackgroundImage')
|
|
const {
|
|
backgroundImage: defaultBackgroundImage,
|
|
// backgroundColor: defaultBackgroundColor,
|
|
backgroundMime: defaultBackgroundMime,
|
|
defaultShippedBackground,
|
|
} = loadState('theming', 'themingDefaults')
|
|
|
|
const prefixWithBaseUrl = (url) => generateFilePath('theming', '', 'img/background/') + url
|
|
|
|
export default {
|
|
name: 'BackgroundSettings',
|
|
|
|
components: {
|
|
Check,
|
|
ColorPalette,
|
|
ImageEdit,
|
|
NcColorPicker,
|
|
},
|
|
|
|
data() {
|
|
return {
|
|
loading: false,
|
|
Theming: loadState('theming', 'data', {}),
|
|
|
|
// User background image and color settings
|
|
backgroundImage,
|
|
}
|
|
},
|
|
|
|
computed: {
|
|
shippedBackgrounds() {
|
|
return Object.keys(shippedBackgroundList)
|
|
.filter((background) => {
|
|
// If the admin did not changed the global background
|
|
// let's hide the default background to not show it twice
|
|
return background !== defaultShippedBackground || !this.isGlobalBackgroundDefault
|
|
})
|
|
.map((fileName) => {
|
|
return {
|
|
name: fileName,
|
|
url: prefixWithBaseUrl(fileName),
|
|
preview: prefixWithBaseUrl('preview/' + fileName),
|
|
details: shippedBackgroundList[fileName],
|
|
}
|
|
})
|
|
},
|
|
|
|
isGlobalBackgroundDefault() {
|
|
return defaultBackgroundMime === ''
|
|
},
|
|
|
|
isGlobalBackgroundDeleted() {
|
|
return defaultBackgroundMime === 'backgroundColor'
|
|
},
|
|
|
|
cssDefaultBackgroundImage() {
|
|
return `url('${defaultBackgroundImage}')`
|
|
},
|
|
},
|
|
|
|
methods: {
|
|
/**
|
|
* Do we need to invert the text if color is too bright?
|
|
*
|
|
* @param {string} color the hex color
|
|
*/
|
|
invertTextColor(color) {
|
|
return this.calculateLuma(color) > 0.6
|
|
},
|
|
|
|
/**
|
|
* Calculate luminance of provided hex color
|
|
*
|
|
* @param {string} color the hex color
|
|
*/
|
|
calculateLuma(color) {
|
|
const [red, green, blue] = this.hexToRGB(color)
|
|
return (0.2126 * red + 0.7152 * green + 0.0722 * blue) / 255
|
|
},
|
|
|
|
/**
|
|
* Convert hex color to RGB
|
|
*
|
|
* @param {string} hex the hex color
|
|
*/
|
|
hexToRGB(hex) {
|
|
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
|
|
return result
|
|
? [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16)]
|
|
: null
|
|
},
|
|
|
|
/**
|
|
* Update local state
|
|
*
|
|
* @param {object} data destructuring object
|
|
* @param {string} data.backgroundColor background color value
|
|
* @param {string} data.backgroundImage background image value
|
|
* @param {string} data.version cache buster number
|
|
* @see https://github.com/nextcloud/server/blob/c78bd45c64d9695724fc44fe8453a88824b85f2f/apps/theming/lib/Controller/UserThemeController.php#L187-L191
|
|
*/
|
|
async update(data) {
|
|
// Update state
|
|
this.backgroundImage = data.backgroundImage
|
|
this.Theming.backgroundColor = data.backgroundColor
|
|
|
|
// Notify parent and reload style
|
|
this.$emit('update:background')
|
|
this.loading = false
|
|
},
|
|
|
|
async setDefault() {
|
|
this.loading = 'default'
|
|
const result = await axios.post(generateUrl('/apps/theming/background/default'))
|
|
this.update(result.data)
|
|
},
|
|
|
|
async setShipped(shipped) {
|
|
this.loading = shipped
|
|
const result = await axios.post(generateUrl('/apps/theming/background/shipped'), { value: shipped })
|
|
this.update(result.data)
|
|
},
|
|
|
|
async setFile(path, color = null) {
|
|
this.loading = 'custom'
|
|
const result = await axios.post(generateUrl('/apps/theming/background/custom'), { value: path, color })
|
|
this.update(result.data)
|
|
},
|
|
|
|
async removeBackground() {
|
|
this.loading = 'remove'
|
|
const result = await axios.delete(generateUrl('/apps/theming/background/custom'))
|
|
this.update(result.data)
|
|
},
|
|
|
|
async pickColor(color) {
|
|
this.loading = 'color'
|
|
const { data } = await axios.post(generateUrl('/apps/theming/background/color'), { color: color || '#0082c9' })
|
|
this.update(data)
|
|
},
|
|
|
|
debouncePickColor: debounce(function(...args) {
|
|
this.pickColor(...args)
|
|
}, 200),
|
|
|
|
pickFile() {
|
|
const picker = getFilePickerBuilder(t('theming', 'Select a background from your files'))
|
|
.allowDirectories(false)
|
|
.setMimeTypeFilter(['image/png', 'image/gif', 'image/jpeg', 'image/svg+xml', 'image/svg'])
|
|
.setMultiSelect(false)
|
|
.addButton({
|
|
id: 'select',
|
|
label: t('theming', 'Select background'),
|
|
callback: (nodes) => {
|
|
this.applyFile(nodes[0]?.path)
|
|
},
|
|
type: 'primary',
|
|
})
|
|
.build()
|
|
picker.pick()
|
|
},
|
|
|
|
async applyFile(path) {
|
|
if (!path || typeof path !== 'string' || path.trim().length === 0 || path === '/') {
|
|
console.error('No valid background have been selected', { path })
|
|
showError(t('theming', 'No background has been selected'))
|
|
return
|
|
}
|
|
|
|
this.loading = 'custom'
|
|
|
|
// Extract primary color from image
|
|
let response = null
|
|
let color = null
|
|
try {
|
|
const fileUrl = generateRemoteUrl('dav/files/' + getCurrentUser().uid + path)
|
|
response = await axios.get(fileUrl, { responseType: 'blob' })
|
|
const blobUrl = URL.createObjectURL(response.data)
|
|
const palette = await this.getColorPaletteFromBlob(blobUrl)
|
|
|
|
// DarkVibrant is accessible AND visually pleasing
|
|
// Vibrant is not accessible enough and others are boring
|
|
color = palette?.DarkVibrant?.hex
|
|
this.setFile(path, color)
|
|
|
|
// Log data
|
|
console.debug('Extracted colour', color, 'from custom image', path, palette)
|
|
} catch (error) {
|
|
this.setFile(path)
|
|
console.error('Unable to extract colour from custom image', { error, path, response, color })
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Extract a Vibrant color palette from a blob URL
|
|
*
|
|
* @param {string} blobUrl the blob URL
|
|
* @return {Promise<Palette>}
|
|
*/
|
|
getColorPaletteFromBlob(blobUrl) {
|
|
return new Promise((resolve, reject) => {
|
|
const vibrant = new Vibrant(blobUrl)
|
|
vibrant.getPalette((error, palette) => {
|
|
if (error) {
|
|
reject(error)
|
|
}
|
|
resolve(palette)
|
|
})
|
|
})
|
|
},
|
|
},
|
|
}
|
|
</script>
|
|
|
|
<style scoped lang="scss">
|
|
.background-selector {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
justify-content: center;
|
|
|
|
.background-color {
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
width: 176px;
|
|
height: 96px;
|
|
margin: 8px;
|
|
border-radius: var(--border-radius-large);
|
|
background-color: var(--color-primary);
|
|
}
|
|
|
|
.background {
|
|
overflow: hidden;
|
|
width: 176px;
|
|
height: 96px;
|
|
margin: 8px;
|
|
text-align: center;
|
|
word-wrap: break-word;
|
|
hyphens: auto;
|
|
border: 2px solid var(--color-main-background);
|
|
border-radius: var(--border-radius-large);
|
|
background-position: center center;
|
|
background-size: cover;
|
|
|
|
&__filepicker {
|
|
background-color: var(--color-background-dark);
|
|
|
|
&.background--active {
|
|
color: var(--color-background-plain-text);
|
|
background-image: var(--image-background);
|
|
}
|
|
}
|
|
|
|
&__default {
|
|
background-color: var(--color-background-plain);
|
|
background-image: linear-gradient(to bottom, rgba(23, 23, 23, 0.5), rgba(23, 23, 23, 0.5)), v-bind(cssDefaultBackgroundImage);
|
|
}
|
|
|
|
&__filepicker, &__default, &__color {
|
|
border-color: var(--color-border);
|
|
}
|
|
|
|
// Over a background image
|
|
&__default,
|
|
&__shipped {
|
|
color: white;
|
|
}
|
|
|
|
// Text and svg icon dark on bright background
|
|
&[data-color-bright] {
|
|
color: black;
|
|
}
|
|
|
|
&--active,
|
|
&:hover,
|
|
&:focus {
|
|
outline: 2px solid var(--color-main-text) !important;
|
|
border-color: var(--color-main-background) !important;
|
|
}
|
|
|
|
// Icon
|
|
span {
|
|
margin: 4px;
|
|
}
|
|
|
|
.check-icon {
|
|
display: none;
|
|
}
|
|
|
|
&--active:not(.icon-loading) {
|
|
.check-icon {
|
|
// Show checkmark
|
|
display: block !important;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
</style>
|
|
|