diff options
Diffstat (limited to 'apps/theming/src/components')
-rw-r--r-- | apps/theming/src/components/AppOrderSelector.vue | 199 | ||||
-rw-r--r-- | apps/theming/src/components/AppOrderSelectorElement.vue | 224 | ||||
-rw-r--r-- | apps/theming/src/components/BackgroundSettings.vue | 354 | ||||
-rw-r--r-- | apps/theming/src/components/ItemPreview.vue | 174 | ||||
-rw-r--r-- | apps/theming/src/components/UserAppMenuSection.vue | 188 | ||||
-rw-r--r-- | apps/theming/src/components/UserPrimaryColor.vue | 160 | ||||
-rw-r--r-- | apps/theming/src/components/admin/AppMenuSection.vue | 126 | ||||
-rw-r--r-- | apps/theming/src/components/admin/CheckboxField.vue | 85 | ||||
-rw-r--r-- | apps/theming/src/components/admin/ColorPickerField.vue | 157 | ||||
-rw-r--r-- | apps/theming/src/components/admin/FileInputField.vue | 241 | ||||
-rw-r--r-- | apps/theming/src/components/admin/TextField.vue | 78 | ||||
-rw-r--r-- | apps/theming/src/components/admin/shared/field.scss | 15 |
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; + } +} |