aboutsummaryrefslogtreecommitdiffstats
path: root/apps/theming/src
diff options
context:
space:
mode:
Diffstat (limited to 'apps/theming/src')
-rw-r--r--apps/theming/src/AdminTheming.vue365
-rw-r--r--apps/theming/src/UserTheming.vue336
-rw-r--r--apps/theming/src/admin-settings.js18
-rw-r--r--apps/theming/src/components/AppOrderSelector.vue199
-rw-r--r--apps/theming/src/components/AppOrderSelectorElement.vue224
-rw-r--r--apps/theming/src/components/BackgroundSettings.vue354
-rw-r--r--apps/theming/src/components/ItemPreview.vue174
-rw-r--r--apps/theming/src/components/UserAppMenuSection.vue188
-rw-r--r--apps/theming/src/components/UserPrimaryColor.vue160
-rw-r--r--apps/theming/src/components/admin/AppMenuSection.vue126
-rw-r--r--apps/theming/src/components/admin/CheckboxField.vue85
-rw-r--r--apps/theming/src/components/admin/ColorPickerField.vue157
-rw-r--r--apps/theming/src/components/admin/FileInputField.vue241
-rw-r--r--apps/theming/src/components/admin/TextField.vue78
-rw-r--r--apps/theming/src/components/admin/shared/field.scss15
-rw-r--r--apps/theming/src/helpers/refreshStyles.js26
-rw-r--r--apps/theming/src/mixins/admin/FieldMixin.js47
-rw-r--r--apps/theming/src/mixins/admin/TextValueMixin.js95
-rw-r--r--apps/theming/src/personal-settings.js20
19 files changed, 2908 insertions, 0 deletions
diff --git a/apps/theming/src/AdminTheming.vue b/apps/theming/src/AdminTheming.vue
new file mode 100644
index 00000000000..e899024ca53
--- /dev/null
+++ b/apps/theming/src/AdminTheming.vue
@@ -0,0 +1,365 @@
+<!--
+ - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <section>
+ <NcSettingsSection :name="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"
+ data-admin-theming-settings>
+ <div class="admin-theming">
+ <NcNoteCard v-if="!isThemable"
+ type="error"
+ :show-alert="true">
+ <p>{{ notThemableErrorMessage }}</p>
+ </NcNoteCard>
+
+ <!-- Name, web link, slogan... fields -->
+ <TextField v-for="field in textFields"
+ :key="field.name"
+ :data-admin-theming-setting-field="field.name"
+ :default-value="field.defaultValue"
+ :display-name="field.displayName"
+ :maxlength="field.maxlength"
+ :name="field.name"
+ :placeholder="field.placeholder"
+ :type="field.type"
+ :value.sync="field.value"
+ @update:theming="refreshStyles" />
+
+ <!-- Primary color picker -->
+ <ColorPickerField :name="primaryColorPickerField.name"
+ :description="primaryColorPickerField.description"
+ :default-value="primaryColorPickerField.defaultValue"
+ :display-name="primaryColorPickerField.displayName"
+ :value.sync="primaryColorPickerField.value"
+ data-admin-theming-setting-primary-color
+ @update:theming="refreshStyles" />
+
+ <!-- Background color picker -->
+ <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 :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"
+ :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="refreshStyles" />
+ <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="refreshStyles" />
+ <CheckboxField :name="userThemingField.name"
+ :value="userThemingField.value"
+ :default-value="userThemingField.defaultValue"
+ :display-name="userThemingField.displayName"
+ :label="userThemingField.label"
+ :description="userThemingField.description"
+ data-admin-theming-setting-disable-user-theming
+ @update:theming="refreshStyles" />
+ <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>
+ <AppMenuSection :default-apps.sync="defaultApps" />
+ </section>
+</template>
+
+<script>
+import { loadState } from '@nextcloud/initial-state'
+import { refreshStyles } from './helpers/refreshStyles.js'
+
+import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
+import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
+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'
+import AppMenuSection from './components/admin/AppMenuSection.vue'
+
+const {
+ defaultBackgroundURL,
+
+ backgroundMime,
+ backgroundURL,
+ backgroundColor,
+ canThemeIcons,
+ docUrl,
+ docUrlIcons,
+ faviconMime,
+ isThemable,
+ legalNoticeUrl,
+ logoheaderMime,
+ logoMime,
+ name,
+ notThemableErrorMessage,
+ primaryColor,
+ privacyPolicyUrl,
+ slogan,
+ url,
+ userThemingDisabled,
+ defaultApps,
+} = 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 primaryColorPickerField = {
+ name: 'primary_color',
+ value: primaryColor,
+ defaultValue: '#0082c9',
+ displayName: t('theming', 'Primary color'),
+ 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 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: {
+ AppMenuSection,
+ CheckboxField,
+ ColorPickerField,
+ FileInputField,
+ NcNoteCard,
+ NcSettingsSection,
+ TextField,
+ },
+
+ data() {
+ return {
+ backgroundMime,
+ backgroundURL,
+ backgroundColor,
+ defaultBackgroundColor: '#0069c3',
+
+ logoMime,
+
+ textFields,
+ primaryColorPickerField,
+ advancedTextFields,
+ advancedFileInputFields,
+ userThemingField,
+ defaultApps,
+
+ canThemeIcons,
+ docUrl,
+ docUrlIcons,
+ isThemable,
+ notThemableErrorMessage,
+ }
+ },
+
+ computed: {
+ cssBackgroundImage() {
+ if (this.backgroundURL) {
+ return `url('${this.backgroundURL}')`
+ }
+ return 'unset'
+ },
+ },
+
+ watch: {
+ 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)
+
+ 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
+ })
+ },
+ },
+}
+</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: v-bind('backgroundColor');
+ background-image: v-bind('cssBackgroundImage');
+
+ &-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>
diff --git a/apps/theming/src/UserTheming.vue b/apps/theming/src/UserTheming.vue
new file mode 100644
index 00000000000..baebf09bcc5
--- /dev/null
+++ b/apps/theming/src/UserTheming.vue
@@ -0,0 +1,336 @@
+<!--
+ - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <section>
+ <NcSettingsSection :name="t('theming', 'Appearance and accessibility settings')"
+ class="theming">
+ <!-- eslint-disable-next-line vue/no-v-html -->
+ <p v-html="description" />
+ <!-- eslint-disable-next-line vue/no-v-html -->
+ <p v-html="descriptionDetail" />
+
+ <div class="theming__preview-list">
+ <ItemPreview v-for="theme in themes"
+ :key="theme.id"
+ :enforced="theme.id === enforceTheme"
+ :selected="selectedTheme.id === theme.id"
+ :theme="theme"
+ :unique="themes.length === 1"
+ type="theme"
+ @change="changeTheme" />
+ </div>
+
+ <div class="theming__preview-list">
+ <ItemPreview v-for="theme in fonts"
+ :key="theme.id"
+ :selected="theme.enabled"
+ :theme="theme"
+ :unique="fonts.length === 1"
+ type="font"
+ @change="changeFont" />
+ </div>
+
+ <h3>{{ t('theming', 'Misc accessibility options') }}</h3>
+ <NcCheckboxRadioSwitch type="checkbox"
+ :checked="enableBlurFilter === 'yes'"
+ :indeterminate="enableBlurFilter === ''"
+ @update:checked="changeEnableBlurFilter">
+ {{ t('theming', 'Enable blur background filter (may increase GPU load)') }}
+ </NcCheckboxRadioSwitch>
+ </NcSettingsSection>
+
+ <NcSettingsSection :name="t('theming', 'Primary color')"
+ :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 v-if="!isUserThemingDisabled"
+ ref="primaryColor"
+ @refresh-styles="refreshGlobalStyles" />
+ </NcSettingsSection>
+
+ <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')"
+ :description="t('theming', 'In some cases keyboard shortcuts can interfere with accessibility tools. In order to allow focusing on your tool correctly you can disable all keyboard shortcuts here. This will also disable all available shortcuts in apps.')">
+ <NcCheckboxRadioSwitch class="theming__preview-toggle"
+ :checked.sync="shortcutsDisabled"
+ type="switch"
+ @change="changeShortcutsDisabled">
+ {{ t('theming', 'Disable all keyboard shortcuts') }}
+ </NcCheckboxRadioSwitch>
+ </NcSettingsSection>
+
+ <UserAppMenuSection />
+ </section>
+</template>
+
+<script>
+import { showError } from '@nextcloud/dialogs'
+import { loadState } from '@nextcloud/initial-state'
+import { generateOcsUrl } from '@nextcloud/router'
+import { refreshStyles } from './helpers/refreshStyles'
+
+import axios, { isAxiosError } from '@nextcloud/axios'
+
+import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
+import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
+
+import BackgroundSettings from './components/BackgroundSettings.vue'
+import ItemPreview from './components/ItemPreview.vue'
+import UserAppMenuSection from './components/UserAppMenuSection.vue'
+import UserPrimaryColor from './components/UserPrimaryColor.vue'
+
+const availableThemes = loadState('theming', 'themes', [])
+const enforceTheme = loadState('theming', 'enforceTheme', '')
+const shortcutsDisabled = loadState('theming', 'shortcutsDisabled', false)
+const enableBlurFilter = loadState('theming', 'enableBlurFilter', '')
+
+const isUserThemingDisabled = loadState('theming', 'isUserThemingDisabled')
+
+export default {
+ name: 'UserTheming',
+
+ components: {
+ ItemPreview,
+ NcCheckboxRadioSwitch,
+ NcSettingsSection,
+ BackgroundSettings,
+ UserAppMenuSection,
+ UserPrimaryColor,
+ },
+
+ data() {
+ return {
+ availableThemes,
+
+ // Admin defined configs
+ enforceTheme,
+ shortcutsDisabled,
+ isUserThemingDisabled,
+
+ enableBlurFilter,
+ }
+ },
+
+ computed: {
+ themes() {
+ return this.availableThemes.filter(theme => theme.type === 1)
+ },
+
+ fonts() {
+ return this.availableThemes.filter(theme => theme.type === 2)
+ },
+
+ // Selected theme, fallback on first (default) if none
+ selectedTheme() {
+ return this.themes.find(theme => theme.enabled === true) || this.themes[0]
+ },
+
+ description() {
+ return t(
+ 'theming',
+ 'Universal access is very important to us. We follow web standards and check to make everything usable also without mouse, and assistive software such as screenreaders. We aim to be compliant with the {linkstart}Web Content Accessibility Guidelines{linkend} 2.1 on AA level, with the high contrast theme even on AAA level.',
+ {
+ linkstart: '<a target="_blank" href="https://www.w3.org/WAI/standards-guidelines/wcag/" rel="noreferrer nofollow">',
+ linkend: '</a>',
+ },
+ {
+ escape: false,
+ },
+ )
+ },
+
+ descriptionDetail() {
+ return t(
+ 'theming',
+ 'If you find any issues, do not hesitate to report them on {issuetracker}our issue tracker{linkend}. And if you want to get involved, come join {designteam}our design team{linkend}!',
+ {
+ issuetracker: '<a target="_blank" href="https://github.com/nextcloud/server/issues/" rel="noreferrer nofollow">',
+ designteam: '<a target="_blank" href="https://nextcloud.com/design" rel="noreferrer nofollow">',
+ linkend: '</a>',
+ },
+ {
+ escape: false,
+ },
+ )
+ },
+ },
+
+ watch: {
+ shortcutsDisabled(newState) {
+ this.changeShortcutsDisabled(newState)
+ },
+ },
+
+ methods: {
+ // Refresh server-side generated theming CSS
+ async refreshGlobalStyles() {
+ await refreshStyles()
+ this.$nextTick(() => this.$refs.primaryColor.reload())
+ },
+
+ changeTheme({ enabled, id }) {
+ // Reset selected and select new one
+ this.themes.forEach(theme => {
+ if (theme.id === id && enabled) {
+ theme.enabled = true
+ return
+ }
+ theme.enabled = false
+ })
+
+ this.updateBodyAttributes()
+ this.selectItem(enabled, id)
+ },
+
+ changeFont({ enabled, id }) {
+ // Reset selected and select new one
+ this.fonts.forEach(font => {
+ if (font.id === id && enabled) {
+ font.enabled = true
+ return
+ }
+ font.enabled = false
+ })
+
+ this.updateBodyAttributes()
+ this.selectItem(enabled, id)
+ },
+
+ async changeShortcutsDisabled(newState) {
+ if (newState) {
+ await axios({
+ url: generateOcsUrl('apps/provisioning_api/api/v1/config/users/{appId}/{configKey}', {
+ appId: 'theming',
+ configKey: 'shortcuts_disabled',
+ }),
+ data: {
+ configValue: 'yes',
+ },
+ method: 'POST',
+ })
+ } else {
+ await axios({
+ url: generateOcsUrl('apps/provisioning_api/api/v1/config/users/{appId}/{configKey}', {
+ appId: 'theming',
+ configKey: 'shortcuts_disabled',
+ }),
+ method: 'DELETE',
+ })
+ }
+ },
+
+ async changeEnableBlurFilter() {
+ this.enableBlurFilter = this.enableBlurFilter === 'no' ? 'yes' : 'no'
+ await axios({
+ url: generateOcsUrl('apps/provisioning_api/api/v1/config/users/{appId}/{configKey}', {
+ appId: 'theming',
+ configKey: 'force_enable_blur_filter',
+ }),
+ data: {
+ configValue: this.enableBlurFilter,
+ },
+ method: 'POST',
+ })
+ // Refresh the styles
+ this.$emit('update:background')
+ },
+
+ updateBodyAttributes() {
+ const enabledThemesIDs = this.themes.filter(theme => theme.enabled === true).map(theme => theme.id)
+ const enabledFontsIDs = this.fonts.filter(font => font.enabled === true).map(font => font.id)
+
+ this.themes.forEach(theme => {
+ document.body.toggleAttribute(`data-theme-${theme.id}`, theme.enabled)
+ })
+ this.fonts.forEach(font => {
+ document.body.toggleAttribute(`data-theme-${font.id}`, font.enabled)
+ })
+
+ document.body.setAttribute('data-themes', [...enabledThemesIDs, ...enabledFontsIDs].join(','))
+ },
+
+ /**
+ * Commit a change and force reload css
+ * Fetching the file again will trigger the server update
+ *
+ * @param {boolean} enabled the theme state
+ * @param {string} themeId the theme ID to change
+ */
+ async selectItem(enabled, themeId) {
+ try {
+ if (enabled) {
+ await axios({
+ url: generateOcsUrl('apps/theming/api/v1/theme/{themeId}/enable', { themeId }),
+ method: 'PUT',
+ })
+ } else {
+ await axios({
+ url: generateOcsUrl('apps/theming/api/v1/theme/{themeId}', { themeId }),
+ method: 'DELETE',
+ })
+ }
+
+ } catch (error) {
+ console.error('theming: Unable to apply setting.', error)
+ let message = t('theming', 'Unable to apply the setting.')
+ if (isAxiosError(error) && error.response.data.ocs?.meta?.message) {
+ message = `${error.response.data.ocs.meta.message}. ${message}`
+ }
+ showError(message)
+ }
+ },
+ },
+}
+</script>
+
+<style lang="scss" scoped>
+.theming {
+ // Limit width of settings sections for readability
+ p {
+ max-width: 800px;
+ }
+
+ // Proper highlight for links and focus feedback
+ :deep(a) {
+ font-weight: bold;
+
+ &:hover,
+ &:focus {
+ text-decoration: underline;
+ }
+ }
+
+ &__preview-list {
+ --gap: 30px;
+ display: grid;
+ margin-top: var(--gap);
+ column-gap: var(--gap);
+ row-gap: var(--gap);
+ }
+}
+
+.background {
+ &__grid {
+ margin-top: 30px;
+ }
+}
+
+@media (max-width: 1440px) {
+ .theming__preview-list {
+ display: flex;
+ flex-direction: column;
+ }
+}
+</style>
diff --git a/apps/theming/src/admin-settings.js b/apps/theming/src/admin-settings.js
new file mode 100644
index 00000000000..622837658f9
--- /dev/null
+++ b/apps/theming/src/admin-settings.js
@@ -0,0 +1,18 @@
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { getCSPNonce } from '@nextcloud/auth'
+import Vue from 'vue'
+
+import App from './AdminTheming.vue'
+
+// eslint-disable-next-line camelcase
+__webpack_nonce__ = getCSPNonce()
+
+Vue.prototype.OC = OC
+Vue.prototype.t = t
+
+const View = Vue.extend(App)
+const theming = new View()
+theming.$mount('#admin-theming')
diff --git a/apps/theming/src/components/AppOrderSelector.vue b/apps/theming/src/components/AppOrderSelector.vue
new file mode 100644
index 00000000000..9c065211bc1
--- /dev/null
+++ b/apps/theming/src/components/AppOrderSelector.vue
@@ -0,0 +1,199 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <Fragment>
+ <div :id="statusInfoId"
+ aria-live="polite"
+ class="hidden-visually"
+ role="status">
+ {{ statusInfo }}
+ </div>
+ <ol ref="listElement" data-cy-app-order class="order-selector">
+ <AppOrderSelectorElement v-for="app,index in appList"
+ :key="`${app.id}${renderCount}`"
+ ref="selectorElements"
+ :app="app"
+ :aria-details="ariaDetails"
+ :aria-describedby="statusInfoId"
+ :is-first="index === 0 || !!appList[index - 1].default"
+ :is-last="index === value.length - 1"
+ v-on="app.default ? {} : {
+ 'move:up': () => moveUp(index),
+ 'move:down': () => moveDown(index),
+ 'update:focus': () => updateStatusInfo(index),
+ }" />
+ </ol>
+ </Fragment>
+</template>
+
+<script lang="ts">
+import type { PropType } from 'vue'
+
+import { translate as t } from '@nextcloud/l10n'
+import { useSortable } from '@vueuse/integrations/useSortable'
+import { computed, defineComponent, onUpdated, ref } from 'vue'
+import { Fragment } from 'vue-frag'
+
+import AppOrderSelectorElement from './AppOrderSelectorElement.vue'
+
+export interface IApp {
+ id: string // app id
+ icon: string // path to the icon svg
+ label: string // display name
+ default?: boolean // force app as default app
+ app?: string
+}
+
+export default defineComponent({
+ name: 'AppOrderSelector',
+ components: {
+ AppOrderSelectorElement,
+ Fragment,
+ },
+ props: {
+ /**
+ * Details like status information that need to be forwarded to the interactive elements
+ */
+ ariaDetails: {
+ type: String,
+ default: null,
+ },
+ /**
+ * List of apps to reorder
+ */
+ value: {
+ type: Array as PropType<IApp[]>,
+ required: true,
+ },
+ },
+ emits: {
+ /**
+ * Update the apps list on reorder
+ * @param value The new value of the app list
+ */
+ 'update:value': (value: IApp[]) => Array.isArray(value),
+ },
+ setup(props, { emit }) {
+ /**
+ * The Element that contains the app list
+ */
+ const listElement = ref<HTMLElement | null>(null)
+
+ /**
+ * The app list with setter that will ement the `update:value` event
+ */
+ const appList = computed({
+ get: () => props.value,
+ // Ensure the sortable.js does not mess with the default attribute
+ set: (list) => {
+ const newValue = [...list].sort((a, b) => ((b.default ? 1 : 0) - (a.default ? 1 : 0)) || list.indexOf(a) - list.indexOf(b))
+ if (newValue.some(({ id }, index) => id !== props.value[index].id)) {
+ emit('update:value', newValue)
+ } else {
+ // forceUpdate as the DOM has changed because of a drag event, but the reactive state has not -> wrong state
+ renderCount.value += 1
+ }
+ },
+ })
+
+ /**
+ * Helper to force rerender the list in case of a invalid drag event
+ */
+ const renderCount = ref(0)
+
+ /**
+ * Handle drag & drop sorting
+ */
+ useSortable(listElement, appList, { filter: '.order-selector-element--disabled' })
+
+ /**
+ * Array of all AppOrderSelectorElement components used to for keeping the focus after button click
+ */
+ const selectorElements = ref<InstanceType<typeof AppOrderSelectorElement>[]>([])
+
+ /**
+ * We use the updated hook here to verify all selector elements keep the focus on the last pressed button
+ * This is needed to be done in this component to make sure Sortable.JS has finished sorting the elements before focussing an element
+ */
+ onUpdated(() => {
+ selectorElements.value.forEach(element => element.keepFocus())
+ })
+
+ /**
+ * Handle element is moved up
+ * @param index The index of the element that is moved
+ */
+ const moveUp = (index: number) => {
+ const before = index > 1 ? props.value.slice(0, index - 1) : []
+ // skip if not possible, because of default default app
+ if (props.value[index - 1]?.default) {
+ return
+ }
+
+ const after = [props.value[index - 1]]
+ if (index < props.value.length - 1) {
+ after.push(...props.value.slice(index + 1))
+ }
+ emit('update:value', [...before, props.value[index], ...after])
+ }
+
+ /**
+ * Handle element is moved down
+ * @param index The index of the element that is moved
+ */
+ const moveDown = (index: number) => {
+ const before = index > 0 ? props.value.slice(0, index) : []
+ before.push(props.value[index + 1])
+
+ const after = index < (props.value.length - 2) ? props.value.slice(index + 2) : []
+ emit('update:value', [...before, props.value[index], ...after])
+ }
+
+ /**
+ * Additional status information to show to screen reader users for accessibility
+ */
+ const statusInfo = ref('')
+
+ /**
+ * ID to be used on the status info element
+ */
+ const statusInfoId = `sorting-status-info-${(Math.random() + 1).toString(36).substring(7)}`
+
+ /**
+ * Update the status information for the currently selected app
+ * @param index Index of the app that is currently selected
+ */
+ const updateStatusInfo = (index: number) => {
+ statusInfo.value = t('theming', 'Current selected app: {app}, position {position} of {total}', {
+ app: props.value[index].label,
+ position: index + 1,
+ total: props.value.length,
+ })
+ }
+
+ return {
+ appList,
+ listElement,
+
+ moveDown,
+ moveUp,
+
+ statusInfoId,
+ statusInfo,
+ updateStatusInfo,
+
+ renderCount,
+ selectorElements,
+ }
+ },
+})
+</script>
+
+<style scoped lang="scss">
+.order-selector {
+ width: max-content;
+ min-width: 260px; // align with NcSelect
+}
+</style>
diff --git a/apps/theming/src/components/AppOrderSelectorElement.vue b/apps/theming/src/components/AppOrderSelectorElement.vue
new file mode 100644
index 00000000000..fc41e8e6165
--- /dev/null
+++ b/apps/theming/src/components/AppOrderSelectorElement.vue
@@ -0,0 +1,224 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <li :data-cy-app-order-element="app.id"
+ :class="{
+ 'order-selector-element': true,
+ 'order-selector-element--disabled': app.default
+ }"
+ @focusin="$emit('update:focus')">
+ <svg width="20"
+ height="20"
+ viewBox="0 0 20 20"
+ role="presentation">
+ <image preserveAspectRatio="xMinYMin meet"
+ x="0"
+ y="0"
+ width="20"
+ height="20"
+ :xlink:href="app.icon"
+ class="order-selector-element__icon" />
+ </svg>
+
+ <div class="order-selector-element__label">
+ {{ app.label ?? app.id }}
+ </div>
+
+ <div class="order-selector-element__actions">
+ <NcButton v-show="!isFirst && !app.default"
+ ref="buttonUp"
+ :aria-label="t('settings', 'Move up')"
+ :aria-describedby="ariaDescribedby"
+ :aria-details="ariaDetails"
+ data-cy-app-order-button="up"
+ type="tertiary-no-background"
+ @click="moveUp">
+ <template #icon>
+ <IconArrowUp :size="20" />
+ </template>
+ </NcButton>
+ <div v-show="isFirst || !!app.default" aria-hidden="true" class="order-selector-element__placeholder" />
+ <NcButton v-show="!isLast && !app.default"
+ ref="buttonDown"
+ :aria-label="t('settings', 'Move down')"
+ :aria-describedby="ariaDescribedby"
+ :aria-details="ariaDetails"
+ data-cy-app-order-button="down"
+ type="tertiary-no-background"
+ @click="moveDown">
+ <template #icon>
+ <IconArrowDown :size="20" />
+ </template>
+ </NcButton>
+ <div v-show="isLast || !!app.default" aria-hidden="true" class="order-selector-element__placeholder" />
+ </div>
+ </li>
+</template>
+
+<script lang="ts">
+import type { PropType } from 'vue'
+
+import { translate as t } from '@nextcloud/l10n'
+import { defineComponent, nextTick, ref } from 'vue'
+
+import IconArrowDown from 'vue-material-design-icons/ArrowDown.vue'
+import IconArrowUp from 'vue-material-design-icons/ArrowUp.vue'
+import NcButton from '@nextcloud/vue/components/NcButton'
+
+interface IApp {
+ id: string // app id
+ icon: string // path to the icon svg
+ label?: string // display name
+ default?: boolean // for app as default app
+}
+
+export default defineComponent({
+ name: 'AppOrderSelectorElement',
+ components: {
+ IconArrowDown,
+ IconArrowUp,
+ NcButton,
+ },
+ props: {
+ /**
+ * Needs to be forwarded to the buttons (as interactive elements)
+ */
+ ariaDescribedby: {
+ type: String,
+ default: null,
+ },
+ ariaDetails: {
+ type: String,
+ default: null,
+ },
+ app: {
+ type: Object as PropType<IApp>,
+ required: true,
+ },
+ isFirst: {
+ type: Boolean,
+ default: false,
+ },
+ isLast: {
+ type: Boolean,
+ default: false,
+ },
+ },
+ emits: {
+ 'move:up': () => true,
+ 'move:down': () => true,
+ /**
+ * We need this as Sortable.js removes all native focus event listeners
+ */
+ 'update:focus': () => true,
+ },
+ setup(props, { emit }) {
+ const buttonUp = ref()
+ const buttonDown = ref()
+
+ /**
+ * Used to decide if we need to trigger focus() an a button on update
+ */
+ let needsFocus = 0
+
+ /**
+ * Handle move up, ensure focus is kept on the button
+ */
+ const moveUp = () => {
+ emit('move:up')
+ needsFocus = 1 // request focus on buttonUp
+ }
+
+ /**
+ * Handle move down, ensure focus is kept on the button
+ */
+ const moveDown = () => {
+ emit('move:down')
+ needsFocus = -1 // request focus on buttonDown
+ }
+
+ /**
+ * Reset the focus on the last used button.
+ * If the button is now visible anymore (because this element is the first/last) then the opposite button is focussed
+ *
+ * This function is exposed to the "AppOrderSelector" component which triggers this when the list was successfully rerendered
+ */
+ const keepFocus = () => {
+ if (needsFocus !== 0) {
+ // focus requested
+ if ((needsFocus === 1 || props.isLast) && !props.isFirst) {
+ // either requested to btn up and it is not the first, or it was requested to btn down but it is the last
+ nextTick(() => buttonUp.value.$el.focus())
+ } else {
+ nextTick(() => buttonDown.value.$el.focus())
+ }
+ }
+ needsFocus = 0
+ }
+
+ return {
+ buttonUp,
+ buttonDown,
+
+ moveUp,
+ moveDown,
+
+ keepFocus,
+
+ t,
+ }
+ },
+})
+</script>
+
+<style lang="scss" scoped>
+.order-selector-element {
+ // hide default styling
+ list-style: none;
+ // Align children
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ // Spacing
+ gap: 12px;
+ padding-inline: 12px;
+
+ &:hover {
+ background-color: var(--color-background-hover);
+ border-radius: var(--border-radius-large);
+ }
+
+ &--disabled {
+ border-color: var(--color-text-maxcontrast);
+ color: var(--color-text-maxcontrast);
+
+ .order-selector-element__icon {
+ opacity: 75%;
+ }
+ }
+
+ &__actions {
+ flex: 0 0;
+ display: flex;
+ flex-direction: row;
+ gap: 6px;
+ }
+
+ &__label {
+ flex: 1 1;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+
+ &__placeholder {
+ height: 44px;
+ width: 44px;
+ }
+
+ &__icon {
+ filter: var(--background-invert-if-bright);
+ }
+}
+</style>
diff --git a/apps/theming/src/components/BackgroundSettings.vue b/apps/theming/src/components/BackgroundSettings.vue
new file mode 100644
index 00000000000..58b76dd9602
--- /dev/null
+++ b/apps/theming/src/components/BackgroundSettings.vue
@@ -0,0 +1,354 @@
+<!--
+ - SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <div class="background-selector" data-user-theming-background-settings>
+ <!-- Custom background -->
+ <button :aria-pressed="backgroundImage === 'custom'"
+ :class="{
+ 'icon-loading': loading === 'custom',
+ 'background background__filepicker': true,
+ 'background--active': backgroundImage === 'custom'
+ }"
+ data-user-theming-background-custom
+ tabindex="0"
+ @click="pickFile">
+ {{ t('theming', 'Custom background') }}
+ <ImageEdit v-if="backgroundImage !== 'custom'" :size="20" />
+ <Check :size="44" />
+ </button>
+
+ <!-- Custom color picker -->
+ <NcColorPicker v-model="Theming.backgroundColor" @update:value="debouncePickColor">
+ <button :class="{
+ 'icon-loading': loading === 'color',
+ 'background background__color': true,
+ 'background--active': backgroundImage === 'color'
+ }"
+ :aria-pressed="backgroundImage === 'color'"
+ :data-color="Theming.backgroundColor"
+ :data-color-bright="invertTextColor(Theming.backgroundColor)"
+ :style="{ backgroundColor: Theming.backgroundColor, '--border-color': Theming.backgroundColor}"
+ data-user-theming-background-color
+ tabindex="0"
+ @click="backgroundImage !== 'color' && debouncePickColor(Theming.backgroundColor)">
+ {{ t('theming', 'Plain background') /* TRANSLATORS: Background using a single color */ }}
+ <ColorPalette v-if="backgroundImage !== 'color'" :size="20" />
+ <Check :size="44" />
+ </button>
+ </NcColorPicker>
+
+ <!-- Default background -->
+ <button :aria-pressed="backgroundImage === 'default'"
+ :class="{
+ 'icon-loading': loading === 'default',
+ 'background background__default': true,
+ 'background--active': backgroundImage === 'default'
+ }"
+ :data-color-bright="invertTextColor(Theming.defaultBackgroundColor)"
+ :style="{ '--border-color': Theming.defaultBackgroundColor }"
+ data-user-theming-background-default
+ tabindex="0"
+ @click="setDefault">
+ {{ t('theming', 'Default background') }}
+ <Check :size="44" />
+ </button>
+
+ <!-- Background set selection -->
+ <button v-for="shippedBackground in shippedBackgrounds"
+ :key="shippedBackground.name"
+ :title="shippedBackground.details.attribution"
+ :aria-label="shippedBackground.details.description"
+ :aria-pressed="backgroundImage === shippedBackground.name"
+ :class="{
+ 'background background__shipped': true,
+ 'icon-loading': loading === shippedBackground.name,
+ 'background--active': backgroundImage === shippedBackground.name
+ }"
+ :data-color-bright="invertTextColor(shippedBackground.details.background_color)"
+ :data-user-theming-background-shipped="shippedBackground.name"
+ :style="{ backgroundImage: 'url(' + shippedBackground.preview + ')', '--border-color': shippedBackground.details.primary_color }"
+ tabindex="0"
+ @click="setShipped(shippedBackground.name)">
+ <Check :size="44" />
+ </button>
+ </div>
+</template>
+
+<script>
+import { generateFilePath, generateUrl } from '@nextcloud/router'
+import { getFilePickerBuilder, showError } from '@nextcloud/dialogs'
+import { loadState } from '@nextcloud/initial-state'
+import axios from '@nextcloud/axios'
+import debounce from 'debounce'
+import NcColorPicker from '@nextcloud/vue/components/NcColorPicker'
+
+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/PaletteOutline.vue'
+
+const shippedBackgroundList = loadState('theming', 'shippedBackgrounds')
+const backgroundImage = loadState('theming', 'userBackgroundImage')
+const {
+ backgroundImage: defaultBackgroundImage,
+ // backgroundColor: defaultBackgroundColor,
+ backgroundMime: defaultBackgroundMime,
+ defaultShippedBackground,
+} = loadState('theming', 'themingDefaults')
+
+const prefixWithBaseUrl = (url) => generateFilePath('theming', '', 'img/background/') + url
+
+export default {
+ name: 'BackgroundSettings',
+
+ components: {
+ Check,
+ ColorPalette,
+ ImageEdit,
+ NcColorPicker,
+ },
+
+ data() {
+ return {
+ loading: false,
+ Theming: loadState('theming', 'data', {}),
+
+ // User background image and color settings
+ backgroundImage,
+ }
+ },
+
+ computed: {
+ shippedBackgrounds() {
+ return Object.keys(shippedBackgroundList)
+ .filter((background) => {
+ // If the admin did not changed the global background
+ // let's hide the default background to not show it twice
+ return background !== defaultShippedBackground || !this.isGlobalBackgroundDefault
+ })
+ .map((fileName) => {
+ return {
+ name: fileName,
+ url: prefixWithBaseUrl(fileName),
+ preview: prefixWithBaseUrl('preview/' + fileName),
+ details: shippedBackgroundList[fileName],
+ }
+ })
+ },
+
+ isGlobalBackgroundDefault() {
+ return defaultBackgroundMime === ''
+ },
+
+ isGlobalBackgroundDeleted() {
+ return defaultBackgroundMime === 'backgroundColor'
+ },
+
+ cssDefaultBackgroundImage() {
+ return `url('${defaultBackgroundImage}')`
+ },
+ },
+
+ methods: {
+ /**
+ * Do we need to invert the text if color is too bright?
+ *
+ * @param {string} color the hex color
+ */
+ invertTextColor(color) {
+ return this.calculateLuma(color) > 0.6
+ },
+
+ /**
+ * Calculate luminance of provided hex color
+ *
+ * @param {string} color the hex color
+ */
+ calculateLuma(color) {
+ const [red, green, blue] = this.hexToRGB(color)
+ return (0.2126 * red + 0.7152 * green + 0.0722 * blue) / 255
+ },
+
+ /**
+ * Convert hex color to RGB
+ *
+ * @param {string} hex the hex color
+ */
+ hexToRGB(hex) {
+ const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
+ return result
+ ? [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16)]
+ : null
+ },
+
+ /**
+ * Update local state
+ *
+ * @param {object} data destructuring object
+ * @param {string} data.backgroundColor background color value
+ * @param {string} data.backgroundImage background image value
+ * @param {string} data.version cache buster number
+ * @see https://github.com/nextcloud/server/blob/c78bd45c64d9695724fc44fe8453a88824b85f2f/apps/theming/lib/Controller/UserThemeController.php#L187-L191
+ */
+ async update(data) {
+ // Update state
+ this.backgroundImage = data.backgroundImage
+ this.Theming.backgroundColor = data.backgroundColor
+
+ // Notify parent and reload style
+ this.$emit('update:background')
+ this.loading = false
+ },
+
+ async setDefault() {
+ this.loading = 'default'
+ const result = await axios.post(generateUrl('/apps/theming/background/default'))
+ this.update(result.data)
+ },
+
+ async setShipped(shipped) {
+ this.loading = shipped
+ const result = await axios.post(generateUrl('/apps/theming/background/shipped'), { value: shipped })
+ this.update(result.data)
+ },
+
+ async setFile(path) {
+ this.loading = 'custom'
+ const result = await axios.post(generateUrl('/apps/theming/background/custom'), { value: path })
+ this.update(result.data)
+ },
+
+ async removeBackground() {
+ this.loading = 'remove'
+ const result = await axios.delete(generateUrl('/apps/theming/background/custom'))
+ this.update(result.data)
+ },
+
+ async pickColor(color) {
+ this.loading = 'color'
+ const { data } = await axios.post(generateUrl('/apps/theming/background/color'), { color: color || '#0082c9' })
+ this.update(data)
+ },
+
+ debouncePickColor: debounce(function(...args) {
+ this.pickColor(...args)
+ }, 1000),
+
+ pickFile() {
+ const picker = getFilePickerBuilder(t('theming', 'Select a background from your files'))
+ .allowDirectories(false)
+ .setMimeTypeFilter(['image/png', 'image/gif', 'image/jpeg', 'image/svg+xml', 'image/svg'])
+ .setMultiSelect(false)
+ .addButton({
+ id: 'select',
+ label: t('theming', 'Select background'),
+ callback: (nodes) => {
+ this.applyFile(nodes[0]?.path)
+ },
+ type: 'primary',
+ })
+ .build()
+ picker.pick()
+ },
+
+ async applyFile(path) {
+ if (!path || typeof path !== 'string' || path.trim().length === 0 || path === '/') {
+ console.error('No valid background have been selected', { path })
+ showError(t('theming', 'No background has been selected'))
+ return
+ }
+
+ this.loading = 'custom'
+ this.setFile(path)
+ },
+ },
+}
+</script>
+
+<style scoped lang="scss">
+.background-selector {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+
+ .background-color {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ width: 176px;
+ height: 96px;
+ margin: 8px;
+ border-radius: var(--border-radius-large);
+ background-color: var(--color-primary);
+ }
+
+ .background {
+ overflow: hidden;
+ width: 176px;
+ height: 96px;
+ margin: 8px;
+ text-align: center;
+ word-wrap: break-word;
+ hyphens: auto;
+ border: 2px solid var(--color-main-background);
+ border-radius: var(--border-radius-large);
+ background-position: center center;
+ background-size: cover;
+
+ &__filepicker {
+ background-color: var(--color-background-dark);
+
+ &.background--active {
+ color: var(--color-background-plain-text);
+ background-image: var(--image-background);
+ }
+ }
+
+ &__default {
+ background-color: var(--color-background-plain);
+ background-image: linear-gradient(to bottom, rgba(23, 23, 23, 0.5), rgba(23, 23, 23, 0.5)), v-bind(cssDefaultBackgroundImage);
+ }
+
+ &__filepicker, &__default, &__color {
+ border-color: var(--color-border);
+ }
+
+ // Over a background image
+ &__default,
+ &__shipped {
+ color: white;
+ }
+
+ // Text and svg icon dark on bright background
+ &[data-color-bright] {
+ color: black;
+ }
+
+ &--active,
+ &:hover,
+ &:focus {
+ outline: 2px solid var(--color-main-text) !important;
+ border-color: var(--color-main-background) !important;
+ }
+
+ // Icon
+ span {
+ margin: 4px;
+ }
+
+ .check-icon {
+ display: none;
+ }
+
+ &--active:not(.icon-loading) {
+ .check-icon {
+ // Show checkmark
+ display: block !important;
+ }
+ }
+ }
+}
+
+</style>
diff --git a/apps/theming/src/components/ItemPreview.vue b/apps/theming/src/components/ItemPreview.vue
new file mode 100644
index 00000000000..e4a1acd3e2a
--- /dev/null
+++ b/apps/theming/src/components/ItemPreview.vue
@@ -0,0 +1,174 @@
+<!--
+ - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <div :class="'theming__preview--' + theme.id" class="theming__preview">
+ <div class="theming__preview-image" :style="{ backgroundImage: 'url(' + img + ')' }" @click="onToggle" />
+ <div class="theming__preview-description">
+ <h3>{{ theme.title }}</h3>
+ <p class="theming__preview-explanation">
+ {{ theme.description }}
+ </p>
+ <span v-if="enforced" class="theming__preview-warning" role="note">
+ {{ t('theming', 'Theme selection is enforced') }}
+ </span>
+
+ <!-- Only show checkbox if we can change themes -->
+ <NcCheckboxRadioSwitch v-show="!enforced"
+ class="theming__preview-toggle"
+ :checked.sync="checked"
+ :disabled="enforced"
+ :name="name"
+ :type="switchType">
+ {{ theme.enableLabel }}
+ </NcCheckboxRadioSwitch>
+ </div>
+ </div>
+</template>
+
+<script>
+import { generateFilePath } from '@nextcloud/router'
+import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
+
+export default {
+ name: 'ItemPreview',
+ components: {
+ NcCheckboxRadioSwitch,
+ },
+ props: {
+ enforced: {
+ type: Boolean,
+ default: false,
+ },
+ selected: {
+ type: Boolean,
+ default: false,
+ },
+ theme: {
+ type: Object,
+ required: true,
+ },
+ type: {
+ type: String,
+ default: '',
+ },
+ unique: {
+ type: Boolean,
+ default: false,
+ },
+ },
+ computed: {
+ switchType() {
+ return this.unique ? 'switch' : 'radio'
+ },
+
+ name() {
+ return !this.unique ? this.type : null
+ },
+
+ img() {
+ return generateFilePath('theming', 'img', this.theme.id + '.jpg')
+ },
+
+ checked: {
+ get() {
+ return this.selected
+ },
+ set(checked) {
+ if (this.enforced) {
+ return
+ }
+
+ console.debug('Changed theme', this.theme.id, checked)
+
+ // If this is a radio, we can only enable
+ if (!this.unique) {
+ this.$emit('change', { enabled: true, id: this.theme.id })
+ return
+ }
+
+ // If this is a switch, we can disable the theme
+ this.$emit('change', { enabled: checked === true, id: this.theme.id })
+ },
+ },
+ },
+
+ methods: {
+ onToggle() {
+ if (this.enforced) {
+ return
+ }
+
+ if (this.switchType === 'radio') {
+ this.checked = true
+ return
+ }
+
+ // Invert state
+ this.checked = !this.checked
+ },
+ },
+}
+</script>
+<style lang="scss" scoped>
+@use 'sass:math';
+
+.theming__preview {
+ // We make previews on 16/10 screens
+ --ratio: 16;
+ position: relative;
+ display: flex;
+ justify-content: flex-start;
+
+ &,
+ * {
+ user-select: none;
+ }
+
+ &-image {
+ flex-basis: calc(16px * var(--ratio));
+ flex-shrink: 0;
+ height: calc(10px * var(--ratio));
+ margin-inline-end: var(--gap);
+ cursor: pointer;
+ border-radius: var(--border-radius);
+ background-repeat: no-repeat;
+ background-position: top left;
+ background-size: cover;
+ }
+
+ &-explanation {
+ margin-bottom: 10px;
+ }
+
+ &-description {
+ display: flex;
+ flex-direction: column;
+
+ h3 {
+ font-weight: bold;
+ margin-bottom: 0;
+ }
+
+ label {
+ padding: 12px 0;
+ }
+ }
+
+ &-warning {
+ color: var(--color-warning);
+ }
+}
+
+@media (max-width: math.div(1024px, 1.5)) {
+ .theming__preview {
+ flex-direction: column;
+
+ &-image {
+ margin: 0;
+ }
+ }
+}
+
+</style>
diff --git a/apps/theming/src/components/UserAppMenuSection.vue b/apps/theming/src/components/UserAppMenuSection.vue
new file mode 100644
index 00000000000..d4221190f6b
--- /dev/null
+++ b/apps/theming/src/components/UserAppMenuSection.vue
@@ -0,0 +1,188 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <NcSettingsSection :name="t('theming', 'Navigation bar settings')">
+ <p>
+ {{ t('theming', 'You can configure the app order used for the navigation bar. The first entry will be the default app, opened after login or when clicking on the logo.') }}
+ </p>
+ <NcNoteCard v-if="enforcedDefaultApp" :id="elementIdEnforcedDefaultApp" type="info">
+ {{ t('theming', 'The default app can not be changed because it was configured by the administrator.') }}
+ </NcNoteCard>
+ <NcNoteCard v-if="hasAppOrderChanged" :id="elementIdAppOrderChanged" type="info">
+ {{ t('theming', 'The app order was changed, to see it in action you have to reload the page.') }}
+ </NcNoteCard>
+
+ <AppOrderSelector class="user-app-menu-order"
+ :aria-details="ariaDetailsAppOrder"
+ :value="appOrder"
+ @update:value="updateAppOrder" />
+
+ <NcButton data-test-id="btn-apporder-reset"
+ :disabled="!hasCustomAppOrder"
+ type="tertiary"
+ @click="resetAppOrder">
+ <template #icon>
+ <IconUndo :size="20" />
+ </template>
+ {{ t('theming', 'Reset default app order') }}
+ </NcButton>
+ </NcSettingsSection>
+</template>
+
+<script lang="ts">
+import type { IApp } from './AppOrderSelector.vue'
+import type { INavigationEntry } from '../../../../core/src/types/navigation.d.ts'
+
+import { showError } from '@nextcloud/dialogs'
+import { loadState } from '@nextcloud/initial-state'
+import { translate as t } from '@nextcloud/l10n'
+import { generateOcsUrl } from '@nextcloud/router'
+import { computed, defineComponent, ref } from 'vue'
+
+import axios from '@nextcloud/axios'
+import AppOrderSelector from './AppOrderSelector.vue'
+import IconUndo from 'vue-material-design-icons/Undo.vue'
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
+import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
+
+/** The app order user setting */
+type IAppOrder = Record<string, { order: number, app?: string }>
+
+/** OCS responses */
+interface IOCSResponse<T> {
+ ocs: {
+ meta: unknown
+ data: T
+ }
+}
+
+export default defineComponent({
+ name: 'UserAppMenuSection',
+ components: {
+ AppOrderSelector,
+ IconUndo,
+ NcButton,
+ NcNoteCard,
+ NcSettingsSection,
+ },
+ setup() {
+ const {
+ /** The app order currently defined by the user */
+ userAppOrder,
+ /** The enforced default app set by the administrator (if any) */
+ enforcedDefaultApp,
+ } = loadState<{ userAppOrder: IAppOrder, enforcedDefaultApp: string }>('theming', 'navigationBar')
+
+ /**
+ * Array of all available apps, it is set by a core controller for the app menu, so it is always available
+ */
+ const initialAppOrder = loadState<INavigationEntry[]>('core', 'apps')
+ .filter(({ type }) => type === 'link')
+ .map((app) => ({ ...app, label: app.name, default: app.default && app.id === enforcedDefaultApp }))
+
+ /**
+ * Check if a custom app order is used or the default is shown
+ */
+ const hasCustomAppOrder = ref(!Array.isArray(userAppOrder) || Object.values(userAppOrder).length > 0)
+
+ /**
+ * Track if the app order has changed, so the user can be informed to reload
+ */
+ const hasAppOrderChanged = computed(() => initialAppOrder.some(({ id }, index) => id !== appOrder.value[index].id))
+
+ /** ID of the "app order has changed" NcNodeCard, used for the aria-details of the apporder */
+ const elementIdAppOrderChanged = 'theming-apporder-changed-infocard'
+
+ /** ID of the "you can not change the default app" NcNodeCard, used for the aria-details of the apporder */
+ const elementIdEnforcedDefaultApp = 'theming-apporder-changed-infocard'
+
+ /**
+ * The aria-details value of the app order selector
+ * contains the space separated list of element ids of NcNoteCards
+ */
+ const ariaDetailsAppOrder = computed(() => (hasAppOrderChanged.value ? `${elementIdAppOrderChanged} ` : '') + (enforcedDefaultApp ? elementIdEnforcedDefaultApp : ''))
+
+ /**
+ * The current apporder (sorted by user)
+ */
+ const appOrder = ref([...initialAppOrder])
+
+ /**
+ * Update the app order, called when the user sorts entries
+ * @param value The new app order value
+ */
+ const updateAppOrder = (value: IApp[]) => {
+ const order: IAppOrder = {}
+ value.forEach(({ app, id }, index) => {
+ order[id] = { order: index, app }
+ })
+
+ saveSetting('apporder', order)
+ .then(() => {
+ appOrder.value = value as never
+ hasCustomAppOrder.value = true
+ })
+ .catch((error) => {
+ console.warn('Could not set the app order', error)
+ showError(t('theming', 'Could not set the app order'))
+ })
+ }
+
+ /**
+ * Reset the app order to the default
+ */
+ const resetAppOrder = async () => {
+ try {
+ await saveSetting('apporder', [])
+ hasCustomAppOrder.value = false
+
+ // Reset our app order list
+ const { data } = await axios.get<IOCSResponse<INavigationEntry[]>>(generateOcsUrl('/core/navigation/apps'), {
+ headers: {
+ 'OCS-APIRequest': 'true',
+ },
+ })
+ appOrder.value = data.ocs.data.map((app) => ({ ...app, label: app.name, default: app.default && app.app === enforcedDefaultApp }))
+ } catch (error) {
+ console.warn(error)
+ showError(t('theming', 'Could not reset the app order'))
+ }
+ }
+
+ const saveSetting = async (key: string, value: unknown) => {
+ const url = generateOcsUrl('apps/provisioning_api/api/v1/config/users/{appId}/{configKey}', {
+ appId: 'core',
+ configKey: key,
+ })
+ return await axios.post(url, {
+ configValue: JSON.stringify(value),
+ })
+ }
+
+ return {
+ appOrder,
+ updateAppOrder,
+ resetAppOrder,
+
+ enforcedDefaultApp,
+ hasAppOrderChanged,
+ hasCustomAppOrder,
+
+ ariaDetailsAppOrder,
+ elementIdAppOrderChanged,
+ elementIdEnforcedDefaultApp,
+
+ t,
+ }
+ },
+})
+</script>
+
+<style scoped lang="scss">
+.user-app-menu-order {
+ margin-block: 12px;
+}
+</style>
diff --git a/apps/theming/src/components/UserPrimaryColor.vue b/apps/theming/src/components/UserPrimaryColor.vue
new file mode 100644
index 00000000000..f10b8a01825
--- /dev/null
+++ b/apps/theming/src/components/UserPrimaryColor.vue
@@ -0,0 +1,160 @@
+<!--
+ - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <div class="primary-color__wrapper">
+ <NcColorPicker v-model="primaryColor"
+ data-user-theming-primary-color
+ @update:value="debouncedOnUpdate">
+ <button ref="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="isdefaultPrimaryColor" @click="onReset">
+ <template #icon>
+ <IconUndo :size="20" />
+ </template>
+ {{ t('theming', 'Reset primary color') }}
+ </NcButton>
+ </div>
+</template>
+
+<script lang="ts">
+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 { defineComponent } from 'vue'
+import axios from '@nextcloud/axios'
+import debounce from 'debounce'
+
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcColorPicker from '@nextcloud/vue/components/NcColorPicker'
+import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
+import IconColorPalette from 'vue-material-design-icons/PaletteOutline.vue'
+import IconUndo from 'vue-material-design-icons/UndoVariant.vue'
+
+const { primaryColor, defaultPrimaryColor } = loadState('theming', 'data', { primaryColor: '#0082c9', defaultPrimaryColor: '#0082c9' })
+
+export default defineComponent({
+ name: 'UserPrimaryColor',
+
+ components: {
+ IconColorPalette,
+ IconUndo,
+ NcButton,
+ NcColorPicker,
+ NcLoadingIcon,
+ },
+
+ emits: ['refresh-styles'],
+
+ data() {
+ return {
+ primaryColor,
+ loading: false,
+ }
+ },
+
+ computed: {
+ isdefaultPrimaryColor() {
+ return colord(this.primaryColor).isEqual(colord(defaultPrimaryColor))
+ },
+
+ debouncedOnUpdate() {
+ return debounce(this.onUpdate, 1000)
+ },
+ },
+
+ methods: {
+ t,
+
+ /**
+ * Global styles are reloaded so we might need to update the current value
+ */
+ reload() {
+ const trigger = this.$refs.trigger as HTMLButtonElement
+ const newColor = window.getComputedStyle(trigger).backgroundColor
+ if (newColor.toLowerCase() !== this.primaryColor) {
+ this.primaryColor = newColor
+ }
+ },
+
+ 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 {
+ 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)
+ showError(t('theming', 'Could not set primary color'))
+ }
+ this.loading = false
+ },
+ },
+})
+</script>
+
+<style scoped lang="scss">
+.primary-color {
+ &__wrapper {
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ gap: 12px;
+ }
+
+ &__trigger {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+
+ background-color: var(--color-primary);
+ color: var(--color-primary-text);
+ width: 350px;
+ max-width: 100vw;
+ height: 96px;
+
+ word-wrap: break-word;
+ hyphens: auto;
+
+ border: 2px solid var(--color-main-background);
+ border-radius: var(--border-radius-large);
+
+ &:active {
+ background-color: var(--color-primary-hover) !important;
+ }
+
+ &:hover,
+ &:focus,
+ &:focus-visible {
+ border-color: var(--color-main-background) !important;
+ outline: 2px solid var(--color-main-text) !important;
+ }
+ }
+}
+</style>
diff --git a/apps/theming/src/components/admin/AppMenuSection.vue b/apps/theming/src/components/admin/AppMenuSection.vue
new file mode 100644
index 00000000000..bf229f15df4
--- /dev/null
+++ b/apps/theming/src/components/admin/AppMenuSection.vue
@@ -0,0 +1,126 @@
+<!--
+ - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+<template>
+ <NcSettingsSection :name="t('theming', 'Navigation bar settings')">
+ <h3>{{ t('theming', 'Default app') }}</h3>
+ <p class="info-note">
+ {{ t('theming', 'The default app is the app that is e.g. opened after login or when the logo in the menu is clicked.') }}
+ </p>
+
+ <NcCheckboxRadioSwitch :checked.sync="hasCustomDefaultApp" type="switch" data-cy-switch-default-app="">
+ {{ t('theming', 'Use custom default app') }}
+ </NcCheckboxRadioSwitch>
+
+ <template v-if="hasCustomDefaultApp">
+ <h4>{{ t('theming', 'Global default app') }}</h4>
+ <NcSelect v-model="selectedApps"
+ :close-on-select="false"
+ :placeholder="t('theming', 'Global default apps')"
+ :options="allApps"
+ :multiple="true" />
+ <h5>{{ t('theming', 'Default app priority') }}</h5>
+ <p class="info-note">
+ {{ t('theming', 'If an app is not enabled for a user, the next app with lower priority is used.') }}
+ </p>
+ <AppOrderSelector :value.sync="selectedApps" />
+ </template>
+ </NcSettingsSection>
+</template>
+
+<script lang="ts">
+import type { INavigationEntry } from '../../../../../core/src/types/navigation'
+
+import { showError } from '@nextcloud/dialogs'
+import { loadState } from '@nextcloud/initial-state'
+import { translate as t } from '@nextcloud/l10n'
+import { generateUrl } from '@nextcloud/router'
+import { computed, defineComponent } from 'vue'
+
+import axios from '@nextcloud/axios'
+
+import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
+import NcSelect from '@nextcloud/vue/components/NcSelect'
+import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
+import AppOrderSelector from '../AppOrderSelector.vue'
+
+export default defineComponent({
+ name: 'AppMenuSection',
+ components: {
+ AppOrderSelector,
+ NcCheckboxRadioSwitch,
+ NcSelect,
+ NcSettingsSection,
+ },
+ props: {
+ defaultApps: {
+ type: Array,
+ required: true,
+ },
+ },
+ emits: {
+ 'update:defaultApps': (value: string[]) => Array.isArray(value) && value.every((id) => typeof id === 'string'),
+ },
+ setup(props, { emit }) {
+ const hasCustomDefaultApp = computed({
+ get: () => props.defaultApps.length > 0,
+ set: (checked: boolean) => {
+ if (checked) {
+ emit('update:defaultApps', ['dashboard', 'files'])
+ } else {
+ selectedApps.value = []
+ }
+ },
+ })
+
+ /**
+ * All enabled apps which can be navigated
+ */
+ const allApps = loadState<INavigationEntry[]>('core', 'apps')
+ .map(({ id, name, icon }) => ({ label: name, id, icon }))
+
+ /**
+ * Currently selected app, wrapps the setter
+ */
+ const selectedApps = computed({
+ get: () => props.defaultApps.map((id) => allApps.filter(app => app.id === id)[0]),
+ set(value) {
+ saveSetting('defaultApps', value.map(app => app.id))
+ .then(() => emit('update:defaultApps', value.map(app => app.id)))
+ .catch(() => showError(t('theming', 'Could not set global default apps')))
+ },
+ })
+
+ const saveSetting = async (key: string, value: unknown) => {
+ const url = generateUrl('/apps/theming/ajax/updateAppMenu')
+ return await axios.put(url, {
+ setting: key,
+ value,
+ })
+ }
+
+ return {
+ allApps,
+ selectedApps,
+ hasCustomDefaultApp,
+
+ t,
+ }
+ },
+})
+</script>
+
+<style scoped lang="scss">
+h3, h4 {
+ font-weight: bold;
+}
+
+h4, h5 {
+ margin-block-start: 12px;
+}
+
+.info-note {
+ color: var(--color-text-maxcontrast);
+}
+</style>
diff --git a/apps/theming/src/components/admin/CheckboxField.vue b/apps/theming/src/components/admin/CheckboxField.vue
new file mode 100644
index 00000000000..42d86ded4e7
--- /dev/null
+++ b/apps/theming/src/components/admin/CheckboxField.vue
@@ -0,0 +1,85 @@
+<!--
+ - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <div class="field">
+ <label :for="id">{{ displayName }}</label>
+ <div class="field__row">
+ <NcCheckboxRadioSwitch :id="id"
+ type="switch"
+ :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 from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
+import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
+
+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>
+@use './shared/field' as *;
+
+.field {
+ &__description {
+ color: var(--color-text-maxcontrast);
+ }
+}
+</style>
diff --git a/apps/theming/src/components/admin/ColorPickerField.vue b/apps/theming/src/components/admin/ColorPickerField.vue
new file mode 100644
index 00000000000..4ec6d47fef6
--- /dev/null
+++ b/apps/theming/src/components/admin/ColorPickerField.vue
@@ -0,0 +1,157 @@
+<!--
+ - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <div class="field">
+ <label :for="id">{{ displayName }}</label>
+ <div class="field__row">
+ <NcColorPicker :value.sync="localValue"
+ :advanced-fields="true"
+ @update:value="debounceSave">
+ <NcButton :id="id"
+ class="field__button"
+ type="primary"
+ :aria-label="t('theming', 'Select a custom color')"
+ data-admin-theming-setting-color-picker>
+ <template #icon>
+ <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-color />
+ <NcButton v-if="value !== defaultValue"
+ type="tertiary"
+ :aria-label="t('theming', 'Reset to default')"
+ data-admin-theming-setting-color-reset
+ @click="undo">
+ <template #icon>
+ <Undo :size="20" />
+ </template>
+ </NcButton>
+ </div>
+ <div v-if="description" class="description">
+ {{ description }}
+ </div>
+
+ <NcNoteCard v-if="errorMessage"
+ type="error"
+ :show-alert="true">
+ <p>{{ errorMessage }}</p>
+ </NcNoteCard>
+ </div>
+</template>
+
+<script>
+import { colord } from 'colord'
+import debounce from 'debounce'
+
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcColorPicker from '@nextcloud/vue/components/NcColorPicker'
+import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
+import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
+import Undo from 'vue-material-design-icons/UndoVariant.vue'
+import Palette from 'vue-material-design-icons/Palette.vue'
+
+import TextValueMixin from '../../mixins/admin/TextValueMixin.js'
+
+export default {
+ name: 'ColorPickerField',
+
+ components: {
+ NcButton,
+ NcColorPicker,
+ NcLoadingIcon,
+ NcNoteCard,
+ Undo,
+ Palette,
+ },
+
+ mixins: [
+ TextValueMixin,
+ ],
+
+ props: {
+ name: {
+ type: String,
+ required: true,
+ },
+ description: {
+ type: String,
+ default: '',
+ },
+ value: {
+ type: String,
+ required: true,
+ },
+ textColor: {
+ type: String,
+ default: null,
+ },
+ defaultValue: {
+ type: String,
+ required: true,
+ },
+ displayName: {
+ type: String,
+ required: true,
+ },
+ },
+
+ 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),
+ },
+}
+</script>
+
+<style lang="scss" scoped>
+@use './shared/field' as *;
+
+.description {
+ color: var(--color-text-maxcontrast);
+}
+
+.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: v-bind('value');
+ }
+}
+</style>
diff --git a/apps/theming/src/components/admin/FileInputField.vue b/apps/theming/src/components/admin/FileInputField.vue
new file mode 100644
index 00000000000..d5e0052f5bd
--- /dev/null
+++ b/apps/theming/src/components/admin/FileInputField.vue
@@ -0,0 +1,241 @@
+<!--
+ - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <div class="field">
+ <label :for="id">{{ displayName }}</label>
+ <div class="field__row">
+ <NcButton :id="id"
+ type="secondary"
+ :aria-label="ariaLabel"
+ data-admin-theming-setting-file-picker
+ @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')"
+ data-admin-theming-setting-file-reset
+ @click="undo">
+ <template #icon>
+ <Undo :size="20" />
+ </template>
+ </NcButton>
+ <NcButton v-if="showRemove"
+ type="tertiary"
+ :aria-label="t('theming', 'Remove background image')"
+ data-admin-theming-setting-file-remove
+ @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"
+ :accept="acceptMime"
+ type="file"
+ @change="onChange">
+ </div>
+</template>
+
+<script>
+import axios from '@nextcloud/axios'
+import { generateUrl } from '@nextcloud/router'
+import { loadState } from '@nextcloud/initial-state'
+
+import NcButton from '@nextcloud/vue/components/NcButton'
+import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
+import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
+import Delete from 'vue-material-design-icons/DeleteOutline.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'
+
+const {
+ allowedMimeTypes,
+} = loadState('theming', 'adminThemingParameters', {})
+
+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,
+ default: '',
+ },
+ displayName: {
+ type: String,
+ required: true,
+ },
+ ariaLabel: {
+ type: String,
+ required: true,
+ },
+ },
+
+ data() {
+ return {
+ showLoading: false,
+ acceptMime: (allowedMimeTypes[this.name]
+ || ['image/jpeg', 'image/png', 'image/gif', 'image/webp']).join(','),
+ }
+ },
+
+ 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
+ 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
+ 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>
+@use './shared/field' as *;
+
+.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>
diff --git a/apps/theming/src/components/admin/TextField.vue b/apps/theming/src/components/admin/TextField.vue
new file mode 100644
index 00000000000..6ec52733aed
--- /dev/null
+++ b/apps/theming/src/components/admin/TextField.vue
@@ -0,0 +1,78 @@
+<!--
+ - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ - SPDX-License-Identifier: AGPL-3.0-or-later
+-->
+
+<template>
+ <div class="field">
+ <NcTextField :value.sync="localValue"
+ :label="displayName"
+ :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/components/NcTextField'
+
+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>
diff --git a/apps/theming/src/components/admin/shared/field.scss b/apps/theming/src/components/admin/shared/field.scss
new file mode 100644
index 00000000000..2347f31f7c5
--- /dev/null
+++ b/apps/theming/src/components/admin/shared/field.scss
@@ -0,0 +1,15 @@
+/*!
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+.field {
+ display: flex;
+ flex-direction: column;
+ gap: 4px 0;
+
+ &__row {
+ display: flex;
+ gap: 0 4px;
+ }
+}
diff --git a/apps/theming/src/helpers/refreshStyles.js b/apps/theming/src/helpers/refreshStyles.js
new file mode 100644
index 00000000000..ba198be0a00
--- /dev/null
+++ b/apps/theming/src/helpers/refreshStyles.js
@@ -0,0 +1,26 @@
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+/**
+ * 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()
+ resolve()
+ }
+ document.head.append(newTheme)
+ }))
+
+ // Wait until all themes are loaded
+ await Promise.allSettled(promises)
+}
diff --git a/apps/theming/src/mixins/admin/FieldMixin.js b/apps/theming/src/mixins/admin/FieldMixin.js
new file mode 100644
index 00000000000..743e711777a
--- /dev/null
+++ b/apps/theming/src/mixins/admin/FieldMixin.js
@@ -0,0 +1,47 @@
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+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')
+ }
+ },
+ },
+}
diff --git a/apps/theming/src/mixins/admin/TextValueMixin.js b/apps/theming/src/mixins/admin/TextValueMixin.js
new file mode 100644
index 00000000000..94d63ce1c8c
--- /dev/null
+++ b/apps/theming/src/mixins/admin/TextValueMixin.js
@@ -0,0 +1,95 @@
+/**
+ * SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+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 {
+ /** @type {string|boolean} */
+ localValue: this.value,
+ }
+ },
+
+ computed: {
+ valueToPost() {
+ if (this.type === 'url') {
+ // if this is already encoded just make sure there is no doublequote (HTML XSS)
+ // otherwise simply URL encode
+ return this.isUrlEncoded(this.localValue)
+ ? this.localValue.replaceAll('"', '%22')
+ : encodeURI(this.localValue)
+ }
+ // Convert boolean to string as server expects string value
+ if (typeof this.localValue === 'boolean') {
+ return this.localValue ? 'yes' : 'no'
+ }
+ return this.localValue
+ },
+ },
+
+ methods: {
+ /**
+ * Check if URL is percent-encoded
+ * @param {string} url The URL to check
+ * @return {boolean}
+ */
+ isUrlEncoded(url) {
+ try {
+ return decodeURI(url) !== url
+ } catch {
+ return false
+ }
+ },
+
+ async save() {
+ this.reset()
+ const url = generateUrl('/apps/theming/ajax/updateStylesheet')
+
+ try {
+ await axios.post(url, {
+ setting: this.name,
+ value: this.valueToPost,
+ })
+ this.$emit('update:value', this.localValue)
+ this.handleSuccess()
+ } catch (e) {
+ console.error('Failed to save changes', e)
+ this.errorMessage = e.response?.data.data?.message
+ }
+ },
+
+ async undo() {
+ this.reset()
+ const url = generateUrl('/apps/theming/ajax/undoChanges')
+ try {
+ const { data } = await axios.post(url, {
+ setting: this.name,
+ })
+
+ 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
+ }
+ },
+ },
+}
diff --git a/apps/theming/src/personal-settings.js b/apps/theming/src/personal-settings.js
new file mode 100644
index 00000000000..bbee88e3804
--- /dev/null
+++ b/apps/theming/src/personal-settings.js
@@ -0,0 +1,20 @@
+/**
+ * SPDX-FileCopyrightText: 2018 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+import { getCSPNonce } from '@nextcloud/auth'
+import Vue from 'vue'
+
+import { refreshStyles } from './helpers/refreshStyles.js'
+import App from './UserTheming.vue'
+
+// eslint-disable-next-line camelcase
+__webpack_nonce__ = getCSPNonce()
+
+Vue.prototype.OC = OC
+Vue.prototype.t = t
+
+const View = Vue.extend(App)
+const theming = new View()
+theming.$mount('#theming')
+theming.$on('update:background', refreshStyles)