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