Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>pull/42977/head
@@ -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'); | |||
} |
@@ -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', ''), |
@@ -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()); |
@@ -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%; |
@@ -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 }) { |
@@ -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) |
@@ -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 { |
@@ -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; |
@@ -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> |
@@ -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 |
@@ -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) | |||
} |
@@ -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 |
@@ -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()) |