Browse Source

fix(settings): Make background selector be responsive to user changes

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
pull/42977/head
Ferdinand Thiessen 3 months ago
parent
commit
752e3b9000
No account linked to committer's email address

+ 0
- 38
apps/theming/lib/Listener/BeforeTemplateRenderedListener.php View File

@@ -26,7 +26,6 @@ declare(strict_types=1);
namespace OCA\Theming\Listener;

use OCA\Theming\AppInfo\Application;
use OCA\Theming\Service\BackgroundService;
use OCA\Theming\Service\JSDataService;
use OCA\Theming\Service\ThemeInjectionService;
use OCP\AppFramework\Http\Events\BeforeLoginTemplateRenderedEvent;
@@ -81,43 +80,6 @@ class BeforeTemplateRenderedListener implements IEventListener {

$this->themeInjectionService->injectHeaders();

$user = $this->userSession->getUser();

if (!empty($user)) {
$userId = $user->getUID();

/** User background */
$this->initialState->provideInitialState(
'backgroundImage',
$this->config->getUserValue($userId, Application::APP_ID, 'background_image', BackgroundService::BACKGROUND_DEFAULT),
);

/** User color */
$this->initialState->provideInitialState(
'backgroundColor',
$this->config->getUserValue($userId, Application::APP_ID, 'background_color', BackgroundService::DEFAULT_COLOR),
);

/**
* Admin background. `backgroundColor` if disabled,
* mime type if defined and empty by default
*/
$this->initialState->provideInitialState(
'themingDefaultBackground',
$this->config->getAppValue('theming', 'backgroundMime', ''),
);
$this->initialState->provideInitialState(
'defaultShippedBackground',
BackgroundService::DEFAULT_BACKGROUND_IMAGE,
);

/** List of all shipped backgrounds */
$this->initialState->provideInitialState(
'shippedBackgrounds',
BackgroundService::SHIPPED_BACKGROUNDS,
);
}

// Making sure to inject just after core
\OCP\Util::addScript('theming', 'theming', 'core');
}

+ 4
- 0
apps/theming/lib/Settings/Admin.php View File

@@ -30,6 +30,7 @@ namespace OCA\Theming\Settings;
use OCA\Theming\AppInfo\Application;
use OCA\Theming\Controller\ThemingController;
use OCA\Theming\ImageManager;
use OCA\Theming\Service\BackgroundService;
use OCA\Theming\ThemingDefaults;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
@@ -79,6 +80,9 @@ class Admin implements IDelegatedSettings {
'backgroundColor' => $this->themingDefaults->getDefaultColorBackground(),
'logoMime' => $this->config->getAppValue(Application::APP_ID, 'logoMime', ''),
'allowedMimeTypes' => $allowedMimeTypes,
'backgroundURL' => $this->imageManager->getImageUrl('background'),
'defaultBackgroundURL' => $this->urlGenerator->linkTo(Application::APP_ID, 'img/background/' . BackgroundService::DEFAULT_BACKGROUND_IMAGE),
'defaultBackgroundColor' => BackgroundService::DEFAULT_BACKGROUND_COLOR,
'backgroundMime' => $this->config->getAppValue(Application::APP_ID, 'backgroundMime', ''),
'logoheaderMime' => $this->config->getAppValue(Application::APP_ID, 'logoheaderMime', ''),
'faviconMime' => $this->config->getAppValue(Application::APP_ID, 'faviconMime', ''),

+ 21
- 0
apps/theming/lib/Settings/Personal.php View File

@@ -26,6 +26,7 @@
namespace OCA\Theming\Settings;

use OCA\Theming\ITheme;
use OCA\Theming\Service\BackgroundService;
use OCA\Theming\Service\ThemesService;
use OCA\Theming\ThemingDefaults;
use OCP\App\IAppManager;
@@ -71,6 +72,26 @@ class Personal implements ISettings {
// Get the default app enforced by admin
$forcedDefaultApp = $this->appManager->getDefaultAppForUser(null, false);

/** List of all shipped backgrounds */
$this->initialStateService->provideInitialState('shippedBackgrounds', BackgroundService::SHIPPED_BACKGROUNDS);

/**
* Admin theming
*/
$this->initialStateService->provideInitialState('themingDefaults', [
/** URL of admin configured background image */
'backgroundImage' => $this->themingDefaults->getBackground(),
/** `backgroundColor` if disabled, mime type if defined and empty by default */
'backgroundMime' => $this->config->getAppValue('theming', 'backgroundMime', ''),
/** Admin configured background color */
'backgroundColor' => $this->themingDefaults->getDefaultColorBackground(),
/** Admin configured primary color */
'primaryColor' => $this->themingDefaults->getDefaultColorPrimary(),
/** Nextcloud default background image */
'defaultShippedBackground' => BackgroundService::DEFAULT_BACKGROUND_IMAGE,
]);

$this->initialStateService->provideInitialState('userBackgroundImage', $this->config->getUserValue($this->userId, 'theming', 'background_image', BackgroundService::BACKGROUND_DEFAULT));
$this->initialStateService->provideInitialState('themes', array_values($themes));
$this->initialStateService->provideInitialState('enforceTheme', $enforcedTheme);
$this->initialStateService->provideInitialState('isUserThemingDisabled', $this->themingDefaults->isUserThemingDisabled());

+ 91
- 83
apps/theming/src/AdminTheming.vue View File

@@ -44,7 +44,7 @@
:placeholder="field.placeholder"
:type="field.type"
:value.sync="field.value"
@update:theming="$emit('update:theming')" />
@update:theming="refreshStyles" />

<!-- Primary color picker -->
<ColorPickerField :name="primaryColorPickerField.name"
@@ -53,33 +53,41 @@
:display-name="primaryColorPickerField.displayName"
:value.sync="primaryColorPickerField.value"
data-admin-theming-setting-primary-color
@update:theming="$emit('update:theming')" />
@update:theming="refreshStyles" />

<!-- Background color picker -->
<ColorPickerField :name="backgroundColorPickerField.name"
:description="backgroundColorPickerField.description"
:default-value="defaultBackground"
:display-name="backgroundColorPickerField.displayName"
:value.sync="backgroundColorPickerField.value"
data-admin-theming-setting-primary-color
@update:theming="$emit('update:theming')" />
<ColorPickerField name="background_color"
:description="t('theming', 'Instead of a background image you can also configure a plain background color. If you use a background image changing this color will influence the color of the app menu icons.')"
:default-value.sync="defaultBackgroundColor"
:display-name="t('theming', 'Background color')"
:value.sync="backgroundColor"
data-admin-theming-setting-background-color
@update:theming="refreshStyles" />

<!-- Default background picker -->
<FileInputField v-for="field in fileInputFields"
:key="field.name"
:aria-label="field.ariaLabel"
:data-admin-theming-setting-file="field.name"
:default-mime-value="field.defaultMimeValue"
:display-name="field.displayName"
:mime-name="field.mimeName"
:mime-value.sync="field.mimeValue"
:name="field.name"
@update:theming="$emit('update:theming')" />
<FileInputField :aria-label="t('theming', 'Upload new logo')"
data-admin-theming-setting-file="logo"
:display-name="t('theming', 'Logo')"
mime-name="logoMime"
:mime-value.sync="logoMime"
name="logo"
@update:theming="refreshStyles" />

<FileInputField :aria-label="t('theming', 'Upload new background and login image')"
data-admin-theming-setting-file="background"
:display-name="t('theming', 'Background and login image')"
mime-name="backgroundMime"
:mime-value.sync="backgroundMime"
name="background"
@uploaded="backgroundURL = $event"
@update:theming="refreshStyles" />

<div class="admin-theming__preview" data-admin-theming-preview>
<div class="admin-theming__preview-logo" data-admin-theming-preview-logo />
</div>
</div>
</NcSettingsSection>

<NcSettingsSection :name="t('theming', 'Advanced options')">
<div class="admin-theming-advanced">
<TextField v-for="field in advancedTextFields"
@@ -91,7 +99,7 @@
:display-name="field.displayName"
:placeholder="field.placeholder"
:maxlength="field.maxlength"
@update:theming="$emit('update:theming')" />
@update:theming="refreshStyles" />
<FileInputField v-for="field in advancedFileInputFields"
:key="field.name"
:name="field.name"
@@ -100,7 +108,7 @@
:default-mime-value="field.defaultMimeValue"
:display-name="field.displayName"
:aria-label="field.ariaLabel"
@update:theming="$emit('update:theming')" />
@update:theming="refreshStyles" />
<CheckboxField :name="userThemingField.name"
:value="userThemingField.value"
:default-value="userThemingField.defaultValue"
@@ -108,7 +116,7 @@
:label="userThemingField.label"
:description="userThemingField.description"
data-admin-theming-setting-disable-user-theming
@update:theming="$emit('update:theming')" />
@update:theming="refreshStyles" />
<a v-if="!canThemeIcons"
:href="docUrlIcons"
rel="noreferrer noopener">
@@ -122,6 +130,7 @@

<script>
import { loadState } from '@nextcloud/initial-state'
import { refreshStyles } from './helpers/refreshStyles.js'

import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js'
import NcSettingsSection from '@nextcloud/vue/dist/Components/NcSettingsSection.js'
@@ -132,7 +141,10 @@ import TextField from './components/admin/TextField.vue'
import AppMenuSection from './components/admin/AppMenuSection.vue'

const {
defaultBackgroundURL,

backgroundMime,
backgroundURL,
backgroundColor,
canThemeIcons,
docUrl,
@@ -190,32 +202,6 @@ const primaryColorPickerField = {
description: t('theming', 'The primary color is used for highlighting elements like important buttons. It might get slightly adjusted depending on the current color schema.'),
}

const backgroundColorPickerField = {
name: 'background_color',
value: backgroundColor,
displayName: t('theming', 'Background color'),
description: t('theming', 'Instead of a background image you can also configure a plain background color. If you use a background image changing this color will influence the color of the app menu icons.'),
}

const fileInputFields = [
{
name: 'logo',
mimeName: 'logoMime',
mimeValue: logoMime,
defaultMimeValue: '',
displayName: t('theming', 'Logo'),
ariaLabel: t('theming', 'Upload new logo'),
},
{
name: 'background',
mimeName: 'backgroundMime',
mimeValue: backgroundMime,
defaultMimeValue: '',
displayName: t('theming', 'Background and login image'),
ariaLabel: t('theming', 'Upload new background and login image'),
},
]

const advancedTextFields = [
{
name: 'imprintUrl',
@@ -278,18 +264,17 @@ export default {
TextField,
},

emits: [
'update:theming',
],

textFields,

data() {
return {
backgroundMime,
backgroundURL,
backgroundColor,
defaultBackgroundColor: '#0069c3',

logoMime,

textFields,
backgroundColorPickerField,
primaryColorPickerField,
fileInputFields,
advancedTextFields,
advancedFileInputFields,
userThemingField,
@@ -300,34 +285,64 @@ export default {
docUrlIcons,
isThemable,
notThemableErrorMessage,

defaultBackground: this.calculateDefaultBackground(),
}
},

computed: {
cssBackgroundImage() {
if (this.backgroundURL) {
return `url('${this.backgroundURL}')`
}
return 'unset'
},
},

watch: {
backgroundColorPickerField: {
deep: true,
handler() {
this.defaultBackground = this.calculateDefaultBackground()
},
backgroundMime() {
if (this.backgroundMime === '') {
// Reset URL to default value for preview
this.backgroundURL = defaultBackgroundURL
} else if (this.backgroundMime === 'backgroundColor') {
// Reset URL to empty image when only color is configured
this.backgroundURL = ''
}
},
async backgroundURL() {
// When the background is changed we need to emulate the background color change
if (this.backgroundURL !== '') {
const color = await this.calculateDefaultBackground()
this.defaultBackgroundColor = color
this.backgroundColor = color
}
},
},

async mounted() {
if (this.backgroundURL) {
this.defaultBackgroundColor = await this.calculateDefaultBackground()
}
},

methods: {
refreshStyles,

/**
* Same as on server - if a user uploads an image the mean color will be set as the background color
*/
calculateDefaultBackground() {
const toHex = (num) => `00${num.toString(16)}`.slice(-2)
const style = window.getComputedStyle(document.body).backgroundImage
const match = style.match(/url\("(http.+)"\)/)
if (!match) {
return '#0082c9'
}
const context = document.createElement('canvas').getContext('2d')
const img = new Image()
img.src = match[1]
context.imageSmoothingEnabled = true
context.drawImage(img, 0, 0, 1, 1)
return '#' + [...context.getImageData(0, 0, 1, 1).data.slice(0, 3)].map(toHex).join('')

return new Promise((resolve, reject) => {
const img = new Image()
img.src = this.backgroundURL
img.onload = () => {
const context = document.createElement('canvas').getContext('2d')
context.imageSmoothingEnabled = true
context.drawImage(img, 0, 0, 1, 1)
resolve('#' + [...context.getImageData(0, 0, 1, 1).data.slice(0, 3)].map(toHex).join(''))
}
img.onerror = reject
})
},
},
}
@@ -349,15 +364,8 @@ export default {
background-position: center;
text-align: center;
margin-top: 10px;
/* This is basically https://github.com/nextcloud/server/blob/master/core/css/guest.css
But without the user variables. That way the admin can preview the render as guest*/
/* As guest, there is no user color color-background-plain */
background-color: var(--color-primary-element-default);
/* As guest, there is no user background (--image-background)
1. Empty background if defined
2. Else default background
3. Finally default gradient (should not happened, the background is always defined anyway) */
background-image: var(--image-background-plain, var(--image-background-default));
background-color: v-bind('backgroundColor');
background-image: v-bind('cssBackgroundImage');

&-logo {
width: 20%;

apps/theming/src/UserThemes.vue → apps/theming/src/UserTheming.vue View File

@@ -57,19 +57,19 @@
:description="isUserThemingDisabled
? t('theming', 'Customization has been disabled by your administrator')
: t('theming', 'Set a primary color to highlight important elements. The color used for elements such as primary buttons might differ a bit as it gets adjusted to fulfill accessibility requirements.')">
<UserPrimaryColor @refresh-styles="refreshGlobalStyles" />
<UserPrimaryColor v-if="!isUserThemingDisabled"
ref="primaryColor"
@refresh-styles="refreshGlobalStyles" />
</NcSettingsSection>

<NcSettingsSection :name="t('theming', 'Background and color')"
class="background"
data-user-theming-background-disabled>
<template v-if="isUserThemingDisabled">
<p>{{ t('theming', 'Customization has been disabled by your administrator') }}</p>
</template>
<template v-else>
<p>{{ t('theming', 'The background can be set to an image from the default set, a custom uploaded image, or a plain color. The primary color will automatically be adapted based on this and used for elements like folder icons, primary buttons and highlights.') }}</p>
<BackgroundSettings class="background__grid" @update:background="refreshGlobalStyles" />
</template>
<NcSettingsSection class="background"
:name="t('theming', 'Background and color')"
:description="isUserThemingDisabled
? t('theming', 'Customization has been disabled by your administrator')
: t('theming', 'The background can be set to an image from the default set, a custom uploaded image, or a plain color.')">
<BackgroundSettings v-if="!isUserThemingDisabled"
class="background__grid"
@update:background="refreshGlobalStyles" />
</NcSettingsSection>

<NcSettingsSection :name="t('theming', 'Keyboard shortcuts')"
@@ -89,7 +89,10 @@
<script>
import { generateOcsUrl } from '@nextcloud/router'
import { loadState } from '@nextcloud/initial-state'
import { refreshStyles } from './helpers/refreshStyles'

import axios from '@nextcloud/axios'

import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js'
import NcSettingsSection from '@nextcloud/vue/dist/Components/NcSettingsSection.js'

@@ -97,7 +100,6 @@ import BackgroundSettings from './components/BackgroundSettings.vue'
import ItemPreview from './components/ItemPreview.vue'
import UserAppMenuSection from './components/UserAppMenuSection.vue'
import UserPrimaryColor from './components/UserPrimaryColor.vue'
import { emit } from '@nextcloud/event-bus'

const availableThemes = loadState('theming', 'themes', [])
const enforceTheme = loadState('theming', 'enforceTheme', '')
@@ -106,7 +108,7 @@ const shortcutsDisabled = loadState('theming', 'shortcutsDisabled', false)
const isUserThemingDisabled = loadState('theming', 'isUserThemingDisabled')

export default {
name: 'UserThemes',
name: 'UserTheming',

components: {
ItemPreview,
@@ -183,16 +185,9 @@ export default {

methods: {
// Refresh server-side generated theming CSS
refreshGlobalStyles() {
[...document.head.querySelectorAll('link.theme')].forEach(theme => {
const url = new URL(theme.href)
url.searchParams.set('v', Date.now())
const newTheme = theme.cloneNode()
newTheme.href = url.toString()
newTheme.onload = () => theme.remove()
document.head.append(newTheme)
})
emit('theming:global-styles-refreshed')
async refreshGlobalStyles() {
await refreshStyles()
this.$nextTick(() => this.$refs.primaryColor.reload())
},

changeTheme({ enabled, id }) {

+ 0
- 2
apps/theming/src/admin-settings.js View File

@@ -22,7 +22,6 @@
import { getRequestToken } from '@nextcloud/auth'
import Vue from 'vue'

import { refreshStyles } from './helpers/refreshStyles.js'
import App from './AdminTheming.vue'

// eslint-disable-next-line camelcase
@@ -34,4 +33,3 @@ Vue.prototype.t = t
const View = Vue.extend(App)
const theming = new View()
theming.$mount('#admin-theming')
theming.$on('update:theming', refreshStyles)

+ 32
- 24
apps/theming/src/components/BackgroundSettings.vue View File

@@ -41,17 +41,19 @@
</button>

<!-- Custom color picker -->
<NcColorPicker v-model="Theming.backgroundColor" @input="debouncePickColor">
<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">
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" />
@@ -65,8 +67,8 @@
'background background__default': true,
'background--active': backgroundImage === 'default'
}"
:data-color-bright="invertTextColor(Theming.defaultColor)"
:style="{ '--border-color': Theming.defaultColor }"
:data-color-bright="invertTextColor(Theming.defaultBackgroundColor)"
:style="{ '--border-color': Theming.defaultBackgroundColor }"
data-user-theming-background-default
tabindex="0"
@click="setDefault">
@@ -85,6 +87,7 @@
'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"
@@ -109,10 +112,14 @@ 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 backgroundImage = loadState('theming', 'backgroundImage')
const shippedBackgroundList = loadState('theming', 'shippedBackgrounds')
const themingDefaultBackground = loadState('theming', 'themingDefaultBackground')
const defaultShippedBackground = loadState('theming', 'defaultShippedBackground')
const backgroundImage = loadState('theming', 'userBackgroundImage')
const {
backgroundImage: defaultBackgroundImage,
backgroundColor: defaultBackgroundColor,
backgroundMime: defaultBackgroundMime,
defaultShippedBackground,
} = loadState('theming', 'themingDefaults')

const prefixWithBaseUrl = (url) => generateFilePath('theming', '', 'img/background/') + url

@@ -139,7 +146,12 @@ export default {
computed: {
shippedBackgrounds() {
return Object.keys(shippedBackgroundList)
.map(fileName => {
.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),
@@ -147,22 +159,18 @@ export default {
details: shippedBackgroundList[fileName],
}
})
.filter(background => {
// If the admin did not changed the global background
// let's hide the default background to not show it twice
if (!this.isGlobalBackgroundDeleted && !this.isGlobalBackgroundDefault) {
return background.name !== defaultShippedBackground
}
return true
})
},

isGlobalBackgroundDefault() {
return !!themingDefaultBackground
return defaultBackgroundMime === ''
},

isGlobalBackgroundDeleted() {
return themingDefaultBackground === 'backgroundColor'
return defaultBackgroundMime === 'backgroundColor'
},

cssDefaultBackgroundImage() {
return `url('${defaultBackgroundImage}')`
},
},

@@ -241,12 +249,12 @@ export default {
this.update(result.data)
},

async pickColor(event) {
async pickColor(color) {
this.loading = 'color'
const color = event?.target?.dataset?.color || this.Theming?.color || '#0082c9'
const result = await axios.post(generateUrl('/apps/theming/background/color'), { color })
this.update(result.data)
const { data } = await axios.post(generateUrl('/apps/theming/background/color'), { color: color || '#0082c9' })
this.update(data)
},

debouncePickColor: debounce(function(...args) {
this.pickColor(...args)
}, 200),
@@ -361,8 +369,8 @@ export default {
}

&__default {
background-color: var(--color-primary-default);
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));
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 {

+ 40
- 14
apps/theming/src/components/UserPrimaryColor.vue View File

@@ -1,14 +1,18 @@
<template>
<div class="primary-color__wrapper">
<NcColorPicker v-model="primaryColor" @submit="onUpdate">
<NcColorPicker v-model="primaryColor"
data-user-theming-primary-color
@update:value="debouncedOnUpdate">
<button ref="trigger"
class="color-container primary-color__trigger">
class="color-container primary-color__trigger"
:style="{ 'background-color': primaryColor }"
data-user-theming-primary-color-trigger>
{{ t('theming', 'Primary color') }}
<NcLoadingIcon v-if="loading" />
<IconColorPalette v-else :size="20" />
</button>
</NcColorPicker>
<NcButton type="tertiary" :disabled="primaryColor === defaultColor" @click="primaryColor = defaultColor">
<NcButton type="tertiary" :disabled="isdefaultPrimaryColor" @click="onReset">
<template #icon>
<IconUndo :size="20" />
</template>
@@ -22,6 +26,8 @@ import { showError } from '@nextcloud/dialogs'
import { loadState } from '@nextcloud/initial-state'
import { translate as t } from '@nextcloud/l10n'
import { generateOcsUrl } from '@nextcloud/router'
import { colord } from 'colord'
import { debounce } from 'debounce'
import { defineComponent } from 'vue'

import axios from '@nextcloud/axios'
@@ -30,10 +36,12 @@ import NcColorPicker from '@nextcloud/vue/dist/Components/NcColorPicker.js'
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
import IconColorPalette from 'vue-material-design-icons/Palette.vue'
import IconUndo from 'vue-material-design-icons/UndoVariant.vue'
import { subscribe } from '@nextcloud/event-bus'

const { primaryColor, defaultPrimaryColor } = loadState('theming', 'data', { primaryColor: '#0082c9', defaultPrimaryColor: '#0082c9' })

export default defineComponent({
name: 'UserPrimaryColor',

components: {
IconColorPalette,
IconUndo,
@@ -41,17 +49,24 @@ export default defineComponent({
NcColorPicker,
NcLoadingIcon,
},

emits: ['refresh-styles'],

data() {
const { primaryColor, defaultColor } = loadState('theming', 'data', { primaryColor: '#0082c9', defaultColor: '#0082c9' })
return {
defaultColor,
primaryColor,
loading: false,
}
},

mounted() {
subscribe('theming:global-styles-refreshed', this.onRefresh)
computed: {
isdefaultPrimaryColor() {
return colord(this.primaryColor).isEqual(colord(defaultPrimaryColor))
},

debouncedOnUpdate() {
return debounce(this.onUpdate, 500)
},
},

methods: {
@@ -60,23 +75,33 @@ export default defineComponent({
/**
* Global styles are reloaded so we might need to update the current value
*/
onRefresh() {
const newColor = window.getComputedStyle(this.$refs.trigger).backgroundColor
reload() {
const trigger = this.$refs.trigger as HTMLButtonElement
const newColor = window.getComputedStyle(trigger).backgroundColor
if (newColor.toLowerCase() !== this.primaryColor) {
this.primaryColor = newColor
}
},

async onUpdate(value: string) {
onReset() {
this.primaryColor = defaultPrimaryColor
this.onUpdate(null)
},

async onUpdate(value: string | null) {
this.loading = true
const url = generateOcsUrl('apps/provisioning_api/api/v1/config/users/{appId}/{configKey}', {
appId: 'theming',
configKey: 'primary_color',
})
try {
await axios.post(url, {
configValue: value,
})
if (value) {
await axios.post(url, {
configValue: value,
})
} else {
await axios.delete(url)
}
this.$emit('refresh-styles')
} catch (e) {
console.error('Could not update primary color', e)
@@ -107,6 +132,7 @@ export default defineComponent({
background-color: var(--color-primary);
color: var(--color-primary-text);
width: 350px;
max-width: 100vw;
height: 96px;

word-wrap: break-word;

+ 44
- 5
apps/theming/src/components/admin/ColorPickerField.vue View File

@@ -31,18 +31,21 @@
class="field__button"
type="primary"
:aria-label="t('theming', 'Select a custom color')"
data-admin-theming-setting-primary-color-picker>
data-admin-theming-setting-color-picker>
<template #icon>
<Palette :size="20" />
<NcLoadingIcon v-if="loading"
:appearance="calculatedTextColor === '#ffffff' ? 'light' : 'dark'"
:size="20" />
<Palette v-else :size="20" />
</template>
{{ value }}
</NcButton>
</NcColorPicker>
<div class="field__color-preview" data-admin-theming-setting-primary-color />
<div class="field__color-preview" data-admin-theming-setting-color />
<NcButton v-if="value !== defaultValue"
type="tertiary"
:aria-label="t('theming', 'Reset to default')"
data-admin-theming-setting-primary-color-reset
data-admin-theming-setting-color-reset
@click="undo">
<template #icon>
<Undo :size="20" />
@@ -63,8 +66,10 @@

<script>
import { debounce } from 'debounce'
import { colord } from 'colord'
import NcButton from '@nextcloud/vue/dist/Components/NcButton.js'
import NcColorPicker from '@nextcloud/vue/dist/Components/NcColorPicker.js'
import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
import NcNoteCard from '@nextcloud/vue/dist/Components/NcNoteCard.js'
import Undo from 'vue-material-design-icons/UndoVariant.vue'
import Palette from 'vue-material-design-icons/Palette.vue'
@@ -77,6 +82,7 @@ export default {
components: {
NcButton,
NcColorPicker,
NcLoadingIcon,
NcNoteCard,
Undo,
Palette,
@@ -99,6 +105,10 @@ export default {
type: String,
required: true,
},
textColor: {
type: String,
default: null,
},
defaultValue: {
type: String,
required: true,
@@ -109,9 +119,33 @@ export default {
},
},

emits: ['update:theming'],

data() {
return {
loading: false,
}
},

computed: {
calculatedTextColor() {
const color = colord(this.value)
return color.isLight() ? '#000000' : '#ffffff'
},
usedTextColor() {
if (this.textColor) {
return this.textColor
}
return this.calculatedTextColor
},
},

methods: {
debounceSave: debounce(async function() {
this.loading = true
await this.save()
this.$emit('update:theming')
this.loading = false
}, 200),
},
}
@@ -124,10 +158,15 @@ export default {
}

.field {
&__button {
background-color: v-bind('value') !important;
color: v-bind('usedTextColor') !important;
}

&__color-preview {
width: var(--default-clickable-area);
border-radius: var(--border-radius-large);
background-color: var(--color-primary-default);
background-color: v-bind('value');
}
}
</style>

+ 3
- 2
apps/theming/src/components/admin/FileInputField.vue View File

@@ -126,7 +126,7 @@ export default {
},
defaultMimeValue: {
type: String,
required: true,
default: '',
},
displayName: {
type: String,
@@ -182,9 +182,10 @@ export default {
const url = generateUrl('/apps/theming/ajax/uploadImage')
try {
this.showLoading = true
await axios.post(url, formData)
const { data } = await axios.post(url, formData)
this.showLoading = false
this.$emit('update:mime-value', file.type)
this.$emit('uploaded', data.data.url)
this.handleSuccess()
} catch (e) {
this.showLoading = false

+ 15
- 5
apps/theming/src/helpers/refreshStyles.js View File

@@ -20,14 +20,24 @@
*
*/

export const refreshStyles = () => {
// Refresh server-side generated theming CSS
[...document.head.querySelectorAll('link.theme')].forEach(theme => {
/**
* Refresh server-side generated theming CSS
* This resolves when all themes are reloaded
*/
export async function refreshStyles() {
const themes = [...document.head.querySelectorAll('link.theme')]
const promises = themes.map((theme) => new Promise((resolve) => {
const url = new URL(theme.href)
url.searchParams.set('v', Date.now())
const newTheme = theme.cloneNode()
newTheme.href = url.toString()
newTheme.onload = () => theme.remove()
newTheme.onload = () => {
theme.remove()
resolve()
}
document.head.append(newTheme)
})
}))

// Wait until all themes are loaded
await Promise.allSettled(promises)
}

+ 6
- 2
apps/theming/src/mixins/admin/TextValueMixin.js View File

@@ -64,10 +64,14 @@ export default {
this.reset()
const url = generateUrl('/apps/theming/ajax/undoChanges')
try {
await axios.post(url, {
const { data } = await axios.post(url, {
setting: this.name,
})
this.$emit('update:value', this.defaultValue)

if (data.data.value) {
this.$emit('update:defaultValue', data.data.value)
}
this.$emit('update:value', data.data.value || this.defaultValue)
this.handleSuccess()
} catch (e) {
this.errorMessage = e.response.data.data?.message

+ 1
- 1
apps/theming/src/personal-settings.js View File

@@ -23,7 +23,7 @@ import { getRequestToken } from '@nextcloud/auth'
import Vue from 'vue'

import { refreshStyles } from './helpers/refreshStyles.js'
import App from './UserThemes.vue'
import App from './UserTheming.vue'

// eslint-disable-next-line camelcase
__webpack_nonce__ = btoa(getRequestToken())

Loading…
Cancel
Save