aboutsummaryrefslogtreecommitdiffstats
path: root/apps/theming/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'apps/theming/src/components')
-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
12 files changed, 2001 insertions, 0 deletions
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;
+ }
+}