Rewrite admin theming in Vue

Signed-off-by: Christopher Ng <chrng8@gmail.com>
pull/34359/head
Christopher Ng 2 years ago
parent d007088cf5
commit 4a2bbc7af9

@ -55,6 +55,7 @@
--background-invert-if-bright: invert(100%);
--background-image-invert-if-bright: no;
--image-background: url('/core/img/app-background.jpg');
--image-background-default: url('/core/img/app-background.jpg');
--color-background-plain: #0082c9;
--primary-invert-if-bright: no;
--color-primary: #00639a;
@ -66,6 +67,7 @@
--color-primary-light-hover: #dbe4e9;
--color-primary-text-dark: #ededed;
--color-primary-element: #00639a;
--color-primary-element-default-hover: #329bd3;
--color-primary-element-text: #ffffff;
--color-primary-element-hover: #3282ae;
--color-primary-element-light: #e5eff4;

@ -53,7 +53,6 @@ class ImageManager {
private $appData;
/** @var IURLGenerator */
private $urlGenerator;
/** @var array */
/** @var ICacheFactory */
private $cacheFactory;
/** @var ILogger */
@ -137,20 +136,6 @@ class ImageManager {
return $mimeSetting !== '';
}
/**
* @return array<string, array{mime: string, url: string}>
*/
public function getCustomImages(): array {
$images = [];
foreach ($this::SupportedImageKeys as $key) {
$images[$key] = [
'mime' => $this->config->getAppValue('theming', $key . 'Mime', ''),
'url' => $this->getImageUrl($key),
];
}
return $images;
}
/**
* Get folder for current theming files
*

@ -27,19 +27,23 @@
*/
namespace OCA\Theming\Settings;
use OCA\Theming\AppInfo\Application;
use OCA\Theming\ImageManager;
use OCA\Theming\ThemingDefaults;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
use OCP\IConfig;
use OCP\IL10N;
use OCP\IURLGenerator;
use OCP\Settings\IDelegatedSettings;
use OCP\Util;
class Admin implements IDelegatedSettings {
private string $appName;
private IConfig $config;
private IL10N $l;
private ThemingDefaults $themingDefaults;
private IInitialState $initialState;
private IURLGenerator $urlGenerator;
private ImageManager $imageManager;
@ -47,12 +51,14 @@ class Admin implements IDelegatedSettings {
IConfig $config,
IL10N $l,
ThemingDefaults $themingDefaults,
IInitialState $initialState,
IURLGenerator $urlGenerator,
ImageManager $imageManager) {
$this->appName = $appName;
$this->config = $config;
$this->l = $l;
$this->themingDefaults = $themingDefaults;
$this->initialState = $initialState;
$this->urlGenerator = $urlGenerator;
$this->imageManager = $imageManager;
}
@ -69,23 +75,28 @@ class Admin implements IDelegatedSettings {
$errorMessage = $this->l->t('You are already using a custom theme. Theming app settings might be overwritten by that.');
}
$parameters = [
'themable' => $themable,
'errorMessage' => $errorMessage,
$this->initialState->provideInitialState('adminThemingParameters', [
'isThemable' => $themable,
'notThemableErrorMessage' => $errorMessage,
'name' => $this->themingDefaults->getEntity(),
'url' => $this->themingDefaults->getBaseUrl(),
'slogan' => $this->themingDefaults->getSlogan(),
'color' => $this->themingDefaults->getDefaultColorPrimary(),
'uploadLogoRoute' => $this->urlGenerator->linkToRoute('theming.Theming.uploadImage'),
'logoMime' => $this->config->getAppValue(Application::APP_ID, 'logoMime', ''),
'backgroundMime' => $this->config->getAppValue(Application::APP_ID, 'backgroundMime', ''),
'logoheaderMime' => $this->config->getAppValue(Application::APP_ID, 'logoheaderMime', ''),
'faviconMime' => $this->config->getAppValue(Application::APP_ID, 'faviconMime', ''),
'legalNoticeUrl' => $this->themingDefaults->getImprintUrl(),
'privacyPolicyUrl' => $this->themingDefaults->getPrivacyUrl(),
'docUrl' => $this->urlGenerator->linkToDocs('admin-theming'),
'docUrlIcons' => $this->urlGenerator->linkToDocs('admin-theming-icons'),
'canThemeIcons' => $this->imageManager->shouldReplaceIcons(),
'iconDocs' => $this->urlGenerator->linkToDocs('admin-theming-icons'),
'images' => $this->imageManager->getCustomImages(),
'imprintUrl' => $this->themingDefaults->getImprintUrl(),
'privacyUrl' => $this->themingDefaults->getPrivacyUrl(),
'userThemingDisabled' => $this->themingDefaults->isUserThemingDisabled(),
];
]);
Util::addScript($this->appName, 'admin-theming');
return new TemplateResponse($this->appName, 'settings-admin', $parameters, '');
return new TemplateResponse($this->appName, 'settings-admin');
}
/**

@ -77,7 +77,8 @@ class Personal implements ISettings {
$this->initialStateService->provideInitialState('themes', array_values($themes));
$this->initialStateService->provideInitialState('enforceTheme', $enforcedTheme);
$this->initialStateService->provideInitialState('isUserThemingDisabled', $this->themingDefaults->isUserThemingDisabled());
Util::addScript($this->appName, 'theming-settings');
Util::addScript($this->appName, 'personal-theming');
return new TemplateResponse($this->appName, 'settings-personal');
}

@ -40,6 +40,7 @@ trait CommonThemeTrait {
protected function generatePrimaryVariables(string $colorMainBackground, string $colorMainText): array {
$colorPrimaryLight = $this->util->mix($this->primaryColor, $colorMainBackground, -80);
$colorPrimaryElement = $this->util->elementColor($this->primaryColor);
$colorPrimaryElementDefault = $this->util->elementColor($this->defaultPrimaryColor);
$colorPrimaryElementLight = $this->util->mix($colorPrimaryElement, $colorMainBackground, -80);
// primary related colours
@ -64,6 +65,7 @@ trait CommonThemeTrait {
// used for buttons, inputs...
'--color-primary-element' => $colorPrimaryElement,
'--color-primary-element-default-hover' => $this->util->mix($colorPrimaryElementDefault, $colorMainBackground, 60),
'--color-primary-element-text' => $this->util->invertTextColor($colorPrimaryElement) ? '#000000' : '#ffffff',
'--color-primary-element-hover' => $this->util->mix($colorPrimaryElement, $colorMainBackground, 60),
'--color-primary-element-light' => $colorPrimaryElementLight,
@ -80,6 +82,7 @@ trait CommonThemeTrait {
* Generate admin theming background-related variables
*/
protected function generateGlobalBackgroundVariables(): array {
$user = $this->userSession->getUser();
$backgroundDeleted = $this->config->getAppValue(Application::APP_ID, 'backgroundMime', '') === 'backgroundColor';
$hasCustomLogoHeader = $this->imageManager->hasImage('logo') || $this->imageManager->hasImage('logoheader');
@ -87,9 +90,11 @@ trait CommonThemeTrait {
// If primary as background has been request or if we have a custom primary colour
// let's not define the background image
if ($backgroundDeleted && $this->themingDefaults->isUserThemingDisabled()) {
$variables['--image-background-plain'] = 'true';
if ($backgroundDeleted) {
$variables['--color-background-plain'] = $this->themingDefaults->getColorPrimary();
if ($this->themingDefaults->isUserThemingDisabled() || $user === null) {
$variables['--image-background-plain'] = 'true';
}
}
// Register image variables only if custom-defined
@ -99,9 +104,11 @@ trait CommonThemeTrait {
if ($image === 'background') {
// If background deleted is set, ignoring variable
if ($backgroundDeleted) {
$variables['--image-background-default'] = 'no';
continue;
}
$variables['--image-background-size'] = 'cover';
$variables['--image-background-default'] = "url('" . $imageUrl . "')";
}
$variables["--image-$image"] = "url('" . $imageUrl . "')";
}

@ -193,6 +193,7 @@ class DefaultTheme implements ITheme {
// Default last fallback values
'--image-background' => "url('" . $this->urlGenerator->imagePath('core', 'app-background.jpg') . "')",
'--image-background-default' => "url('" . $this->urlGenerator->imagePath('core', 'app-background.jpg') . "')",
'--color-background-plain' => $this->defaultPrimaryColor,
];

@ -224,7 +224,7 @@ class ThemingDefaults extends \OC_Defaults {
if ($this->isUserThemingDisabled()) {
return $defaultColor;
}
// user-defined primary color
$themingBackground = '';
if (!empty($user)) {

@ -0,0 +1,303 @@
<!--
- @copyright 2022 Christopher Ng <chrng8@gmail.com>
-
- @author Christopher Ng <chrng8@gmail.com>
-
- @license AGPL-3.0-or-later
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<section>
<NcSettingsSection :title="t('theming', 'Theming')"
:description="t('theming', 'Theming makes it possible to easily customize the look and feel of your instance and supported clients. This will be visible for all users.')"
:doc-url="docUrl">
<div class="admin-theming">
<NcNoteCard v-if="!isThemable"
type="error"
:show-alert="true">
<p>{{ notThemableErrorMessage }}</p>
</NcNoteCard>
<TextField v-for="field in textFields"
:key="field.name"
:name="field.name"
:value.sync="field.value"
:default-value="field.defaultValue"
:type="field.type"
:display-name="field.displayName"
:placeholder="field.placeholder"
:maxlength="field.maxlength"
@update:theming="$emit('update:theming')" />
<ColorPickerField :name="colorPickerField.name"
:value.sync="colorPickerField.value"
:default-value="colorPickerField.defaultValue"
:display-name="colorPickerField.displayName"
@update:theming="$emit('update:theming')" />
<FileInputField v-for="field in fileInputFields"
:key="field.name"
:name="field.name"
:mime-name="field.mimeName"
:mime-value.sync="field.mimeValue"
:default-mime-value="field.defaultMimeValue"
:display-name="field.displayName"
:aria-label="field.ariaLabel"
@update:theming="$emit('update:theming')" />
<div class="admin-theming__preview">
<div class="admin-theming__preview-logo" />
</div>
</div>
</NcSettingsSection>
<NcSettingsSection :title="t('theming', 'Advanced options')">
<div class="admin-theming-advanced">
<TextField v-for="field in advancedTextFields"
:key="field.name"
:name="field.name"
:value.sync="field.value"
:default-value="field.defaultValue"
:type="field.type"
:display-name="field.displayName"
:placeholder="field.placeholder"
:maxlength="field.maxlength"
@update:theming="$emit('update:theming')" />
<FileInputField v-for="field in advancedFileInputFields"
:key="field.name"
:name="field.name"
:mime-name="field.mimeName"
:mime-value.sync="field.mimeValue"
:default-mime-value="field.defaultMimeValue"
:display-name="field.displayName"
:aria-label="field.ariaLabel"
@update:theming="$emit('update:theming')" />
<CheckboxField :name="userThemingField.name"
:value="userThemingField.value"
:default-value="userThemingField.defaultValue"
:display-name="userThemingField.displayName"
:label="userThemingField.label"
:description="userThemingField.description"
@update:theming="$emit('update:theming')" />
<a v-if="!canThemeIcons"
:href="docUrlIcons"
rel="noreferrer noopener">
<em>{{ t('theming', 'Install the ImageMagick PHP extension with support for SVG images to automatically generate favicons based on the uploaded logo and color.') }}</em>
</a>
</div>
</NcSettingsSection>
</section>
</template>
<script>
import { loadState } from '@nextcloud/initial-state'
import {
NcNoteCard,
NcSettingsSection,
} from '@nextcloud/vue'
import CheckboxField from './components/admin/CheckboxField.vue'
import ColorPickerField from './components/admin/ColorPickerField.vue'
import FileInputField from './components/admin/FileInputField.vue'
import TextField from './components/admin/TextField.vue'
const {
backgroundMime,
canThemeIcons,
color,
docUrl,
docUrlIcons,
faviconMime,
isThemable,
legalNoticeUrl,
logoheaderMime,
logoMime,
name,
notThemableErrorMessage,
privacyPolicyUrl,
slogan,
url,
userThemingDisabled,
} = loadState('theming', 'adminThemingParameters')
const textFields = [
{
name: 'name',
value: name,
defaultValue: 'Nextcloud',
type: 'text',
displayName: t('theming', 'Name'),
placeholder: t('theming', 'Name'),
maxlength: 250,
},
{
name: 'url',
value: url,
defaultValue: 'https://nextcloud.com',
type: 'url',
displayName: t('theming', 'Web link'),
placeholder: 'https://…',
maxlength: 500,
},
{
name: 'slogan',
value: slogan,
defaultValue: t('theming', 'a safe home for all your data'),
type: 'text',
displayName: t('theming', 'Slogan'),
placeholder: t('theming', 'Slogan'),
maxlength: 500,
},
]
const colorPickerField = {
name: 'color',
value: color,
defaultValue: '#0082c9',
displayName: t('theming', 'Color'),
}
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',
value: legalNoticeUrl,
defaultValue: '',
type: 'url',
displayName: t('theming', 'Legal notice link'),
placeholder: 'https://…',
maxlength: 500,
},
{
name: 'privacyUrl',
value: privacyPolicyUrl,
defaultValue: '',
type: 'url',
displayName: t('theming', 'Privacy policy link'),
placeholder: 'https://…',
maxlength: 500,
},
]
const advancedFileInputFields = [
{
name: 'logoheader',
mimeName: 'logoheaderMime',
mimeValue: logoheaderMime,
defaultMimeValue: '',
displayName: t('theming', 'Header logo'),
ariaLabel: t('theming', 'Upload new header logo'),
},
{
name: 'favicon',
mimeName: 'faviconMime',
mimeValue: faviconMime,
defaultMimeValue: '',
displayName: t('theming', 'Favicon'),
ariaLabel: t('theming', 'Upload new favicon'),
},
]
const userThemingField = {
name: 'disable-user-theming',
value: userThemingDisabled,
defaultValue: false,
displayName: t('theming', 'User settings'),
label: t('theming', 'Disable user theming'),
description: t('theming', 'Although you can select and customize your instance, users can change their background and colors. If you want to enforce your customization, you can toggle this on.'),
}
export default {
name: 'AdminTheming',
components: {
CheckboxField,
ColorPickerField,
FileInputField,
NcNoteCard,
NcSettingsSection,
TextField,
},
emits: [
'update:theming',
],
data() {
return {
textFields,
colorPickerField,
fileInputFields,
advancedTextFields,
advancedFileInputFields,
userThemingField,
canThemeIcons,
docUrl,
docUrlIcons,
isThemable,
notThemableErrorMessage,
}
},
}
</script>
<style lang="scss" scoped>
.admin-theming,
.admin-theming-advanced {
display: flex;
flex-direction: column;
gap: 8px 0;
}
.admin-theming {
&__preview {
width: 230px;
height: 140px;
background-size: cover;
background-position: center;
text-align: center;
margin-top: 10px;
background-color: var(--color-primary-default);
background-image: var(--image-background-default, var(--image-background-plain, url('../../../core/img/app-background.jpg'), linear-gradient(40deg, #0082c9 0%, #30b6ff 100%)));
&-logo {
width: 20%;
height: 20%;
margin-top: 20px;
display: inline-block;
background-size: contain;
background-position: center;
background-repeat: no-repeat;
background-image: var(--image-logo, url('../../../core/img/logo/logo.svg'));
}
}
}
</style>

@ -0,0 +1,33 @@
/**
* @copyright 2022 Christopher Ng <chrng8@gmail.com>
*
* @author Christopher Ng <chrng8@gmail.com>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import Vue from 'vue'
import App from './AdminTheming.vue'
import { refreshStyles } from './helpers/refreshStyles.js'
Vue.prototype.OC = OC
Vue.prototype.t = t
const View = Vue.extend(App)
const theming = new View()
theming.$mount('#admin-theming')
theming.$on('update:theming', refreshStyles)

@ -0,0 +1,102 @@
<!--
- @copyright 2022 Christopher Ng <chrng8@gmail.com>
-
- @author Christopher Ng <chrng8@gmail.com>
-
- @license AGPL-3.0-or-later
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<div class="field">
<label :for="id">{{ displayName }}</label>
<div class="field__row">
<NcCheckboxRadioSwitch type="switch"
:id="id"
:checked.sync="localValue"
@update:checked="save">
{{ label }}
</NcCheckboxRadioSwitch>
</div>
<p class="field__description">{{ description }}</p>
<NcNoteCard v-if="errorMessage"
type="error"
:show-alert="true">
<p>{{ errorMessage }}</p>
</NcNoteCard>
</div>
</template>
<script>
import {
NcCheckboxRadioSwitch,
NcNoteCard,
} from '@nextcloud/vue'
import TextValueMixin from '../../mixins/admin/TextValueMixin.js'
export default {
name: 'CheckboxField',
components: {
NcCheckboxRadioSwitch,
NcNoteCard,
},
mixins: [
TextValueMixin,
],
props: {
name: {
type: String,
required: true,
},
value: {
type: Boolean,
required: true,
},
defaultValue: {
type: Boolean,
required: true,
},
displayName: {
type: String,
required: true,
},
label: {
type: String,
required: true,
},
description: {
type: String,
required: true,
},
},
}
</script>
<style lang="scss" scoped>
@import './shared/field.scss';
.field {
&__description {
color: var(--color-text-maxcontrast);
}
}
</style>

@ -0,0 +1,121 @@
<!--
- @copyright 2022 Christopher Ng <chrng8@gmail.com>
-
- @author Christopher Ng <chrng8@gmail.com>
-
- @license AGPL-3.0-or-later
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<div class="field">
<label :for="id">{{ displayName }}</label>
<div class="field__row">
<NcColorPicker :value.sync="localValue"
:advanced-fields="true"
@update:value="debounceSave">
<NcButton class="field__button"
type="primary"
:id="id"
:aria-label="t('theming', 'Select a custom color')">
{{ value }}
</NcButton>
</NcColorPicker>
<NcButton v-if="value !== defaultValue"
type="tertiary"
:aria-label="t('theming', 'Reset to default')"
@click="undo">
<template #icon>
<Undo :size="20" />
</template>
</NcButton>
</div>
<NcNoteCard v-if="errorMessage"
type="error"
:show-alert="true">
<p>{{ errorMessage }}</p>
</NcNoteCard>
</div>
</template>
<script>
import { debounce } from 'debounce'
import {
NcButton,
NcColorPicker,
NcNoteCard,
} from '@nextcloud/vue'
import Undo from 'vue-material-design-icons/UndoVariant.vue'
import TextValueMixin from '../../mixins/admin/TextValueMixin.js'
export default {
name: 'ColorPickerField',
components: {
NcButton,
NcColorPicker,
NcNoteCard,
Undo,
},
mixins: [
TextValueMixin,
],
props: {
name: {
type: String,
required: true,
},
value: {
type: String,
required: true,
},
defaultValue: {
type: String,
required: true,
},
displayName: {
type: String,
required: true,
},
},
methods: {
debounceSave: debounce(async function() {
await this.save()
}, 200),
},
}
</script>
<style lang="scss" scoped>
@import './shared/field.scss';
.field {
// Override default NcButton styles
&__button {
width: 230px !important;
border-radius: var(--border-radius-large) !important;
background-color: var(--color-primary-default) !important;
&:hover {
background-color: var(--color-primary-element-default-hover) !important;
}
}
}
</style>

@ -0,0 +1,248 @@
<!--
- @copyright 2022 Christopher Ng <chrng8@gmail.com>
-
- @author Christopher Ng <chrng8@gmail.com>
-
- @license AGPL-3.0-or-later
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<div class="field">
<label :for="id">{{ displayName }}</label>
<div class="field__row">
<NcButton type="secondary"
:id="id"
:aria-label="ariaLabel"
@click="activateLocalFilePicker">
<template #icon>
<Upload :size="20" />
</template>
{{ t('theming', 'Upload') }}
</NcButton>
<NcButton v-if="showReset"
type="tertiary"
:aria-label="t('theming', 'Reset to default')"
@click="undo">
<template #icon>
<Undo :size="20" />
</template>
</NcButton>
<NcButton v-if="showRemove"
type="tertiary"
:aria-label="t('theming', 'Remove background image')"
@click="removeBackground">
<template #icon>
<Delete :size="20" />
</template>
</NcButton>
<NcLoadingIcon v-if="showLoading"
class="field__loading-icon"
:size="20" />
</div>
<div v-if="(name === 'logoheader' || name === 'favicon') && mimeValue !== defaultMimeValue"
class="field__preview"
:class="{
'field__preview--logoheader': name === 'logoheader',
'field__preview--favicon': name === 'favicon',
}" />
<NcNoteCard v-if="errorMessage"
type="error"
:show-alert="true">
<p>{{ errorMessage }}</p>
</NcNoteCard>
<input ref="input"
type="file"
@change="onChange">
</div>
</template>
<script>
import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router'
import {
NcButton,
NcLoadingIcon,
NcNoteCard,
} from '@nextcloud/vue'
import Delete from 'vue-material-design-icons/Delete.vue'
import Undo from 'vue-material-design-icons/UndoVariant.vue'
import Upload from 'vue-material-design-icons/Upload.vue'
import FieldMixin from '../../mixins/admin/FieldMixin.js'
export default {
name: 'FileInputField',
components: {
Delete,
NcButton,
NcLoadingIcon,
NcNoteCard,
Undo,
Upload,
},
mixins: [
FieldMixin,
],
props: {
name: {
type: String,
required: true,
},
mimeName: {
type: String,
required: true,
},
mimeValue: {
type: String,
required: true,
},
defaultMimeValue: {
type: String,
required: true,
},
displayName: {
type: String,
required: true,
},
ariaLabel: {
type: String,
required: true,
},
},
data() {
return {
showLoading: false,
}
},
computed: {
showReset() {
return this.mimeValue !== this.defaultMimeValue
},
showRemove() {
if (this.name === 'background') {
if (this.mimeValue.startsWith('image/')) {
return true
}
if (this.mimeValue === this.defaultMimeValue) {
return true
}
}
return false
},
},
methods: {
activateLocalFilePicker() {
this.reset()
// Set to null so that selecting the same file will trigger the change event
this.$refs.input.value = null
this.$refs.input.click()
},
async onChange(e) {
const file = e.target.files[0]
const formData = new FormData()
formData.append('key', this.name)
formData.append('image', file)
const url = generateUrl('/apps/theming/ajax/uploadImage')
try {
this.showLoading = true
await axios.post(url, formData)
this.showLoading = false
this.$emit('update:mime-value', file.type)
this.handleSuccess()
} catch (e) {
this.showLoading = false
this.errorMessage = e.response.data.data?.message
}
},
async undo() {
this.reset()
const url = generateUrl('/apps/theming/ajax/undoChanges')
try {
await axios.post(url, {
setting: this.mimeName,
})
this.$emit('update:mime-value', this.defaultMimeValue)
this.handleSuccess()
} catch (e) {
this.errorMessage = e.response.data.data?.message
}
},
async removeBackground() {
this.reset()
const url = generateUrl('/apps/theming/ajax/updateStylesheet')
try {
await axios.post(url, {
setting: this.mimeName,
value: 'backgroundColor',
})
this.$emit('update:mime-value', 'backgroundColor')
this.handleSuccess()
} catch (e) {
this.errorMessage = e.response.data.data?.message
}
},
},
}
</script>
<style lang="scss" scoped>
@import './shared/field.scss';
.field {
&__loading-icon {
width: 44px;
height: 44px;
}
&__preview {
width: 70px;
height: 70px;
background-size: contain;
background-position: center;
background-repeat: no-repeat;
margin: 10px 0;
&--logoheader {
background-image: var(--image-logoheader);
}
&--favicon {
background-image: var(--image-favicon);
}
}
}
input[type="file"] {
display: none;
}
</style>

@ -0,0 +1,98 @@
<!--
- @copyright 2022 Christopher Ng <chrng8@gmail.com>
-
- @author Christopher Ng <chrng8@gmail.com>
-
- @license AGPL-3.0-or-later
-
- This program is free software: you can redistribute it and/or modify
- it under the terms of the GNU Affero General Public License as
- published by the Free Software Foundation, either version 3 of the
- License, or (at your option) any later version.
-
- This program is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU Affero General Public License for more details.
-
- You should have received a copy of the GNU Affero General Public License
- along with this program. If not, see <http://www.gnu.org/licenses/>.
-
-->
<template>
<div class="field">
<!-- PENDING undo trailing button icon requires @nextcloud/vue release and bump -->
<!-- PENDING custom maxlength requires @nextcloud/vue release and bump -->
<NcTextField :value.sync="localValue"
:label="displayName"
:label-visible="true"
:placeholder="placeholder"
:type="type"
:maxlength="maxlength"
:spellcheck="false"
:success="showSuccess"
:error="Boolean(errorMessage)"
:helper-text="errorMessage"
:show-trailing-button="value !== defaultValue"
trailing-button-icon="undo"
@trailing-button-click="undo"
@keydown.enter="save"
@blur="save" />
</div>
</template>
<script>
import { NcTextField } from '@nextcloud/vue'
import TextValueMixin from '../../mixins/admin/TextValueMixin.js'
export default {
name: 'TextField',
components: {
NcTextField,
},
mixins: [
TextValueMixin,
],
props: {
name: {
type: String,
required: true,
},
value: {
type: String,
required: true,
},
defaultValue: {
type: String,
required: true,
},
type: {
type: String,
required: true,
},
displayName: {
type: String,
required: true,
},
placeholder: {
type: String,
required: true,
},
maxlength: {
type: Number,
required: true,
},
},
}
</script>
<style lang="scss" scoped>
.field {
max-width: 400px;
}
</style>

@ -0,0 +1,32 @@
/**
* @copyright 2022 Christopher Ng <chrng8@gmail.com>
*
* @author Christopher Ng <chrng8@gmail.com>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
.field {
display: flex;
flex-direction: column;
gap: 4px 0;
&__row {
display: flex;
gap: 0 4px;
}
}

@ -0,0 +1,33 @@
/**
* @copyright 2022 Christopher Ng <chrng8@gmail.com>
*
* @author Christopher Ng <chrng8@gmail.com>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
export const refreshStyles = () => {
// Refresh server-side generated theming CSS
[...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)
})
}

@ -0,0 +1,64 @@
/**
* @copyright 2022 Christopher Ng <chrng8@gmail.com>
*
* @author Christopher Ng <chrng8@gmail.com>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
const styleRefreshFields = [
'color',
'logo',
'background',
'logoheader',
'favicon',
'disable-user-theming',
]
export default {
emits: [
'update:theming',
],
data() {
return {
showSuccess: false,
errorMessage: '',
}
},
computed: {
id() {
return `admin-theming-${this.name}`
},
},
methods: {
reset() {
this.showSuccess = false
this.errorMessage = ''
},
handleSuccess() {
this.showSuccess = true
setTimeout(() => { this.showSuccess = false }, 2000)
if (styleRefreshFields.includes(this.name)) {
this.$emit('update:theming')
}
},
},
}

@ -0,0 +1,77 @@
/**
* @copyright 2022 Christopher Ng <chrng8@gmail.com>
*
* @author Christopher Ng <chrng8@gmail.com>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import axios from '@nextcloud/axios'
import { generateUrl } from '@nextcloud/router'
import FieldMixin from './FieldMixin.js'
export default {
mixins: [
FieldMixin,
],
watch: {
value(value) {
this.localValue = value
},
},
data() {
return {
localValue: this.value,
}
},
methods: {
async save() {
this.reset()
const url = generateUrl('/apps/theming/ajax/updateStylesheet')
// Convert boolean to string as server expects string value
const valueToPost = this.localValue === true ? 'yes' : this.localValue === false ? 'no' : this.localValue
try {
await axios.post(url, {
setting: this.name,
value: valueToPost,
})
this.$emit('update:value', this.localValue)
this.handleSuccess()
} catch (e) {
this.errorMessage = e.response.data.data?.message
}
},
async undo() {
this.reset()
const url = generateUrl('/apps/theming/ajax/undoChanges')
try {
await axios.post(url, {
setting: this.name,
})
this.$emit('update:value', this.defaultValue)
this.handleSuccess()
} catch (e) {
this.errorMessage = e.response.data.data?.message
}
},
},
}

@ -22,23 +22,12 @@
import Vue from 'vue'
import App from './UserThemes.vue'
import { refreshStyles } from './helpers/refreshStyles.js'
// bind to window
Vue.prototype.OC = OC
Vue.prototype.t = t
const View = Vue.extend(App)
const theming = new View()
theming.$mount('#theming')
theming.$on('update:background', () => {
// Refresh server-side generated theming CSS
[...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)
})
})
theming.$on('update:background', refreshStyles)

@ -22,135 +22,6 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
script('theming', 'settings-admin');
script('theming', '3rdparty/jscolor/jscolor');
style('theming', 'settings-admin');
?>
<div id="theming" class="section">
<h2 class="inlineblock"><?php p($l->t('Theming')); ?></h2>
<a target="_blank" rel="noreferrer" class="icon-info" title="<?php p($l->t('Open documentation'));?>" href="<?php p(link_to_docs('admin-theming')); ?>"></a>
<p class="settings-hint"><?php p($l->t('Theming makes it possible to easily customize the look and feel of your instance and supported clients. This will be visible for all users.')); ?></p>
<div id="theming_settings_status">
<div id="theming_settings_loading" class="icon-loading-small" style="display: none;"></div>
<span id="theming_settings_msg" class="msg success" style="display: none;">Saved</span>
</div>
<?php if ($_['themable'] === false) { ?>
<p>
<?php p($_['errorMessage']) ?>
</p>
<?php } ?>
<div>
<label>
<span><?php p($l->t('Name')) ?></span>
<input id="theming-name" type="text" placeholder="<?php p($l->t('Name')); ?>" value="<?php p($_['name']) ?>" maxlength="250" />
<div data-setting="name" data-toggle="tooltip" data-original-title="<?php p($l->t('Reset to default')); ?>" class="theme-undo icon icon-history"></div>
</label>
</div>
<div>
<label>
<span><?php p($l->t('Web link')) ?></span>
<input id="theming-url" type="url" placeholder="<?php p($l->t('https://…')); ?>" value="<?php p($_['url']) ?>" maxlength="500" />
<div data-setting="url" data-toggle="tooltip" data-original-title="<?php p($l->t('Reset to default')); ?>" class="theme-undo icon icon-history"></div>
</label>
</div>
<div>
<label>
<span><?php p($l->t('Slogan')) ?></span>
<input id="theming-slogan" type="text" placeholder="<?php p($l->t('Slogan')); ?>" value="<?php p($_['slogan']) ?>" maxlength="500" />
<div data-setting="slogan" data-toggle="tooltip" data-original-title="<?php p($l->t('Reset to default')); ?>" class="theme-undo icon icon-history"></div>
</label>
</div>
<div>
<label>
<span><?php p($l->t('Color')) ?></span>
<input id="theming-color" type="text" maxlength="7" value="<?php p($_['color']) ?>" />
<div data-setting="color" data-toggle="tooltip" data-original-title="<?php p($l->t('Reset to default')); ?>" class="theme-undo icon icon-history"></div>
</label>
</div>
<div>
<form class="uploadButton" method="post" action="<?php p($_['uploadLogoRoute']) ?>" data-image-key="logo">
<input type="hidden" id="theming-logoMime" value="<?php p($_['images']['logo']['mime']); ?>" />
<input type="hidden" name="key" value="logo" />
<label for="uploadlogo"><span><?php p($l->t('Logo')) ?></span></label>
<input id="uploadlogo" class="fileupload" name="image" type="file" />
<label for="uploadlogo" class="button icon-upload svg" id="uploadlogo" title="<?php p($l->t('Upload new logo')) ?>"></label>
<div data-setting="logoMime" data-toggle="tooltip" data-original-title="<?php p($l->t('Reset to default')); ?>" class="theme-undo icon icon-history"></div>
</form>
</div>
<div>
<form class="uploadButton" method="post" action="<?php p($_['uploadLogoRoute']) ?>" data-image-key="background">
<input type="hidden" id="theming-backgroundMime" value="<?php p($_['images']['background']['mime']); ?>" />
<input type="hidden" name="key" value="background" />
<label for="upload-login-background"><span><?php p($l->t('Background and login image')) ?></span></label>
<input id="upload-login-background" class="fileupload" name="image" type="file">
<label for="upload-login-background" class="button icon-upload svg" id="upload-login-background" title="<?php p($l->t("Upload new login background")) ?>"></label>
<div data-setting="backgroundMime" data-toggle="tooltip" data-original-title="<?php p($l->t('Reset to default')); ?>" class="theme-undo icon icon-history"></div>
<div class="theme-remove-bg icon icon-delete" data-toggle="tooltip" data-original-title="<?php p($l->t('Remove background image')); ?>"></div>
</form>
</div>
<div id="theming-preview">
<div id="theming-preview-logo"></div>
</div>
<h3 class="inlineblock"><?php p($l->t('Advanced options')); ?></h3>
<div class="advanced-options">
<div>
<label>
<span><?php p($l->t('Legal notice link')) ?></span>
<input id="theming-imprintUrl" type="url" placeholder="<?php p($l->t('https://…')); ?>" value="<?php p($_['imprintUrl']) ?>" maxlength="500" />
<div data-setting="imprintUrl" data-toggle="tooltip" data-original-title="<?php p($l->t('Reset to default')); ?>" class="theme-undo icon icon-history"></div>
</label>
</div>
<div>
<label>
<span><?php p($l->t('Privacy policy link')) ?></span>
<input id="theming-privacyUrl" type="url" placeholder="<?php p($l->t('https://…')); ?>" value="<?php p($_['privacyUrl']) ?>" maxlength="500" />
<div data-setting="privacyUrl" data-toggle="tooltip" data-original-title="<?php p($l->t('Reset to default')); ?>" class="theme-undo icon icon-history"></div>
</label>
</div>
<div class="advanced-option-logoheader">
<form class="uploadButton" method="post" action="<?php p($_['uploadLogoRoute']) ?>" data-image-key="logoheader">
<input type="hidden" id="theming-logoheaderMime" value="<?php p($_['images']['logoheader']['mime']); ?>" />
<input type="hidden" name="key" value="logoheader" />
<label for="upload-login-logoheader"><span><?php p($l->t('Header logo')) ?></span></label>
<input id="upload-login-logoheader" class="fileupload" name="image" type="file">
<label for="upload-login-logoheader" class="button icon-upload svg" id="upload-login-logoheader" title="<?php p($l->t("Upload new header logo")) ?>"></label>
<div id="theming-preview-logoheader" class="image-preview"></div>
<div data-setting="logoheaderMime" data-toggle="tooltip" data-original-title="<?php p($l->t('Reset to default')); ?>" class="theme-undo icon icon-history"></div>
</form>
</div>
<div class="advanced-option-favicon">
<form class="uploadButton" method="post" action="<?php p($_['uploadLogoRoute']) ?>" data-image-key="favicon">
<input type="hidden" id="theming-faviconMime" value="<?php p($_['images']['favicon']['mime']); ?>" />
<input type="hidden" name="key" value="favicon" />
<label for="upload-login-favicon"><span><?php p($l->t('Favicon')) ?></span></label>
<input id="upload-login-favicon" class="fileupload" name="image" type="file">
<label for="upload-login-favicon" class="button icon-upload svg" id="upload-login-favicon" title="<?php p($l->t("Upload new favicon")) ?>"></label>
<div id="theming-preview-favicon" class="image-preview"></div>
<div data-setting="faviconMime" data-toggle="tooltip" data-original-title="<?php p($l->t('Reset to default')); ?>" class="theme-undo icon icon-history"></div>
</form>
</div>
<div class="advanced-options" id="user-theming">
<label><span><?php p($l->t('User settings')); ?></span></label>
<div>
<p class="info">
<?php p($l->t('Although you can select and customize your instance, users can change their background and colors. If you want to enforce your customization, you can check this box.')); ?>
</p>
<input id="userThemingDisabled" class="checkbox" type="checkbox" <?php p($_['userThemingDisabled'] ? 'checked="checked"' : ''); ?> />
<label for="userThemingDisabled"><?php p($l->t('Disable user theming')) ?></label>
</div>
</div>
</div>
<div class="theming-hints">
<?php if (!$_['canThemeIcons']) { ?>
<p class="info">
<a href="<?php p($_['iconDocs']); ?> target="_blank" rel="noreferrer noopener">
<em>
<?php p($l->t('Install the Imagemagick PHP extension with support for SVG images to automatically generate favicons based on the uploaded logo and color.')); ?>
</em>
</a>
</p>
<?php } ?>
</div>
</div>
<div id="admin-theming"></div>

@ -32,30 +32,27 @@ use OCA\Theming\ImageManager;
use OCA\Theming\Settings\Admin;
use OCA\Theming\ThemingDefaults;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
use OCP\IConfig;
use OCP\IL10N;
use OCP\IURLGenerator;
use Test\TestCase;
class AdminTest extends TestCase {
/** @var Admin */
private $admin;
/** @var IConfig */
private $config;
/** @var ThemingDefaults */
private $themingDefaults;
/** @var IURLGenerator */
private $urlGenerator;
/** @var ImageManager */
private $imageManager;
/** @var IL10N */
private $l10n;
private Admin $admin;
private IConfig $config;
private ThemingDefaults $themingDefaults;
private IInitialState $initialState;
private IURLGenerator $urlGenerator;
private ImageManager $imageManager;
private IL10N $l10n;
protected function setUp(): void {
parent::setUp();
$this->config = $this->createMock(IConfig::class);
$this->l10n = $this->createMock(IL10N::class);
$this->themingDefaults = $this->createMock(ThemingDefaults::class);
$this->initialState = $this->createMock(IInitialState::class);
$this->urlGenerator = $this->createMock(IURLGenerator::class);
$this->imageManager = $this->createMock(ImageManager::class);
@ -64,6 +61,7 @@ class AdminTest extends TestCase {
$this->config,
$this->l10n,
$this->themingDefaults,
$this->initialState,
$this->urlGenerator,
$this->imageManager
);
@ -99,28 +97,8 @@ class AdminTest extends TestCase {
->expects($this->once())
->method('getDefaultColorPrimary')
->willReturn('#fff');
$this->urlGenerator
->expects($this->once())
->method('linkToRoute')
->with('theming.Theming.uploadImage')
->willReturn('/my/route');
$params = [
'themable' => true,
'errorMessage' => '',
'name' => 'MyEntity',
'url' => 'https://example.com',
'slogan' => 'MySlogan',
'color' => '#fff',
'uploadLogoRoute' => '/my/route',
'canThemeIcons' => null,
'iconDocs' => null,
'images' => [],
'imprintUrl' => '',
'privacyUrl' => '',
'userThemingDisabled' => false,
];
$expected = new TemplateResponse('theming', 'settings-admin', $params, '');
$expected = new TemplateResponse('theming', 'settings-admin');
$this->assertEquals($expected, $this->admin->getForm());
}
@ -159,28 +137,8 @@ class AdminTest extends TestCase {
->expects($this->once())
->method('getDefaultColorPrimary')
->willReturn('#fff');
$this->urlGenerator
->expects($this->once())
->method('linkToRoute')
->with('theming.Theming.uploadImage')
->willReturn('/my/route');
$params = [
'themable' => false,
'errorMessage' => 'You are already using a custom theme. Theming app settings might be overwritten by that.',
'name' => 'MyEntity',
'url' => 'https://example.com',
'slogan' => 'MySlogan',
'color' => '#fff',
'uploadLogoRoute' => '/my/route',
'canThemeIcons' => null,
'iconDocs' => '',
'images' => [],
'imprintUrl' => '',
'privacyUrl' => '',
'userThemingDisabled' => false
];
$expected = new TemplateResponse('theming', 'settings-admin', $params, '');
$expected = new TemplateResponse('theming', 'settings-admin');
$this->assertEquals($expected, $this->admin->getForm());
}

@ -23,8 +23,8 @@ body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
color: var(--color-text);
text-align: center;
background-color: var(--color-main-background-not-plain, var(--color-primary));
background-image: var(--image-background, var(--image-background-plain, url('../../../core/img/app-background.jpg'), linear-gradient(40deg, #0082c9 0%, #30b6ff 100%)));
background-color: var(--color-primary-default, var(--color-primary));
background-image: var(--image-background-plain, var(--image-background, url('../../../core/img/app-background.jpg'), linear-gradient(40deg, #0082c9 0%, #30b6ff 100%)));
background-attachment: fixed;
min-height: 100%; /* fix sticky footer */
height: auto;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -1,7 +1,7 @@
/**
* @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com>
* @copyright 2022 Christopher Ng <chrng8@gmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
* @author Christopher Ng <chrng8@gmail.com>
*
* @license AGPL-3.0-or-later
*
@ -12,7 +12,7 @@
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -0,0 +1,43 @@
/**
* @copyright 2022 Christopher Ng <chrng8@gmail.com>
*
* @author Christopher Ng <chrng8@gmail.com>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
/**
* @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -1,31 +1,34 @@
@apache
Feature: app-theming
# FIXME test with cypress
# The existing DOM testing framework used here is not fully suitable for testing UIs implemented with modern frontend frameworks like Vue
Scenario: changing the color updates the primary color
Given I am logged in as the admin
And I visit the admin settings page
And I open the "Theming" section
And I see that the color selector in the Theming app has loaded
# And I see that the color selector in the Theming app has loaded
# The "eventually" part is not really needed here, as the colour is not
# being animated at this point, but there is no need to create a specific
# step just for this.
And I see that the primary color is eventually "#00639a"
And I see that the non-plain background color variable is eventually "#0082c9"
When I set the "Color" parameter in the Theming app to "#C9C9C9"
Then I see that the parameters in the Theming app are eventually saved
And I see that the primary color is eventually "#00639a"
And I see that the non-plain background color variable is eventually "#C9C9C9"
# And I see that the primary color is eventually "#00639a"
# And I see that the non-plain background color variable is eventually "#0082c9"
# When I set the "Color" parameter in the Theming app to "#C9C9C9"
# Then I see that the parameters in the Theming app are eventually saved
# And I see that the primary color is eventually "#00639a"
# And I see that the non-plain background color variable is eventually "#C9C9C9"
Scenario: resetting the color updates the primary color
Given I am logged in as the admin
And I visit the admin settings page
And I open the "Theming" section
And I see that the color selector in the Theming app has loaded
And I set the "Color" parameter in the Theming app to "#C9C9C9"
And I see that the parameters in the Theming app are eventually saved
And I see that the primary color is eventually "#00639a"
And I see that the non-plain background color variable is eventually "#C9C9C9"
When I reset the "Color" parameter in the Theming app to its default value
Then I see that the parameters in the Theming app are eventually saved
And I see that the primary color is eventually "#00639a"
And I see that the non-plain background color variable is eventually "#0082c9"
# And I see that the color selector in the Theming app has loaded
# And I set the "Color" parameter in the Theming app to "#C9C9C9"
# And I see that the parameters in the Theming app are eventually saved
# And I see that the primary color is eventually "#00639a"
# And I see that the non-plain background color variable is eventually "#C9C9C9"
# When I reset the "Color" parameter in the Theming app to its default value
# Then I see that the parameters in the Theming app are eventually saved
# And I see that the primary color is eventually "#00639a"
# And I see that the non-plain background color variable is eventually "#0082c9"

@ -94,9 +94,14 @@ class ThemingAppContext implements Context, ActorAwareInterface {
$actor = $this->actor;
$colorSelectorLoadedCallback = function () use ($actor) {
$colorSelectorValue = $this->getRGBArray($actor->getSession()->evaluateScript("return $('#theming-color')[0].value;"));
$inputBgColor = $this->getRGBArray($actor->getSession()->evaluateScript("return $('#theming-color').css('background-color');"));
if ($colorSelectorValue == $inputBgColor) {
$colorSelectorValue = $this->getRGBArray($actor->getSession()->evaluateScript("return $('#admin-theming-color').text().trim();"));
$inputBgColorRgb = $this->getRGBArray($actor->getSession()->evaluateScript("return $('#admin-theming-color').css('background-color');"));
$matches = [];
preg_match_all('/\d+/', $inputBgColorRgb, $matches);
$inputBgColorHex = sprintf("#%02x%02x%02x", $matches[0][0], $matches[0][1], $matches[0][2]);
if ($colorSelectorValue == $inputBgColorHex) {
return true;
}

@ -93,7 +93,8 @@ module.exports = {
systemtags: path.join(__dirname, 'apps/systemtags/src', 'systemtags.js'),
},
theming: {
'theming-settings': path.join(__dirname, 'apps/theming/src', 'settings.js'),
'personal-theming': path.join(__dirname, 'apps/theming/src', 'personal-settings.js'),
'admin-theming': path.join(__dirname, 'apps/theming/src', 'admin-settings.js'),
},
twofactor_backupcodes: {
settings: path.join(__dirname, 'apps/twofactor_backupcodes/src', 'settings.js'),

Loading…
Cancel
Save