diff options
Diffstat (limited to 'apps/settings/src/components/PersonalInfo')
42 files changed, 2136 insertions, 2021 deletions
diff --git a/apps/settings/src/components/PersonalInfo/AvatarSection.vue b/apps/settings/src/components/PersonalInfo/AvatarSection.vue new file mode 100644 index 00000000000..a99f228668c --- /dev/null +++ b/apps/settings/src/components/PersonalInfo/AvatarSection.vue @@ -0,0 +1,309 @@ +<!-- + - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <section id="vue-avatar-section"> + <HeaderBar :is-heading="true" + :readable="avatar.readable" + :scope.sync="avatar.scope" /> + + <div v-if="!showCropper" class="avatar__container"> + <div class="avatar__preview"> + <NcAvatar v-if="!loading" + :key="version" + :user="userId" + :aria-label="t('settings', 'Your profile picture')" + :disable-tooltip="true" + :show-user-status="false" + :size="180" /> + <div v-else class="icon-loading" /> + </div> + <template v-if="avatarChangeSupported"> + <div class="avatar__buttons"> + <NcButton :aria-label="t('settings', 'Upload profile picture')" + @click="activateLocalFilePicker"> + <template #icon> + <Upload :size="20" /> + </template> + </NcButton> + <NcButton :aria-label="t('settings', 'Choose profile picture from Files')" + @click="openFilePicker"> + <template #icon> + <Folder :size="20" /> + </template> + </NcButton> + <NcButton v-if="!isGenerated" + :aria-label="t('settings', 'Remove profile picture')" + @click="removeAvatar"> + <template #icon> + <Delete :size="20" /> + </template> + </NcButton> + </div> + <span>{{ t('settings', 'The file must be a PNG or JPG') }}</span> + <input ref="input" + type="file" + :accept="validMimeTypes.join(',')" + @change="onChange"> + </template> + <span v-else> + {{ t('settings', 'Picture provided by original account') }} + </span> + </div> + + <!-- Use v-show to ensure early cropper ref availability --> + <div v-show="showCropper" class="avatar__container"> + <VueCropper ref="cropper" + class="avatar__cropper" + v-bind="cropperOptions" /> + <div class="avatar__cropper-buttons"> + <NcButton @click="cancel"> + {{ t('settings', 'Cancel') }} + </NcButton> + <NcButton type="primary" + @click="saveAvatar"> + {{ t('settings', 'Set as profile picture') }} + </NcButton> + </div> + <span>{{ t('settings', 'Please note that it can take up to 24 hours for your profile picture to be updated everywhere.') }}</span> + </div> + </section> +</template> + +<script> +import axios from '@nextcloud/axios' +import { loadState } from '@nextcloud/initial-state' +import { generateUrl } from '@nextcloud/router' +import { getCurrentUser } from '@nextcloud/auth' +import { getFilePickerBuilder, showError } from '@nextcloud/dialogs' +import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus' + +import NcAvatar from '@nextcloud/vue/components/NcAvatar' +import NcButton from '@nextcloud/vue/components/NcButton' +import VueCropper from 'vue-cropperjs' +// eslint-disable-next-line n/no-extraneous-import +import 'cropperjs/dist/cropper.css' + +import Upload from 'vue-material-design-icons/Upload.vue' +import Folder from 'vue-material-design-icons/Folder.vue' +import Delete from 'vue-material-design-icons/DeleteOutline.vue' + +import HeaderBar from './shared/HeaderBar.vue' +import { NAME_READABLE_ENUM } from '../../constants/AccountPropertyConstants.js' + +const { avatar } = loadState('settings', 'personalInfoParameters', {}) +const { avatarChangeSupported } = loadState('settings', 'accountParameters', {}) + +const VALID_MIME_TYPES = ['image/png', 'image/jpeg'] + +const picker = getFilePickerBuilder(t('settings', 'Choose your profile picture')) + .setMultiSelect(false) + .setMimeTypeFilter(VALID_MIME_TYPES) + .setType(1) + .allowDirectories(false) + .build() + +export default { + name: 'AvatarSection', + + components: { + Delete, + Folder, + HeaderBar, + NcAvatar, + NcButton, + Upload, + VueCropper, + }, + + data() { + return { + avatar: { ...avatar, readable: NAME_READABLE_ENUM[avatar.name] }, + avatarChangeSupported, + showCropper: false, + loading: false, + userId: getCurrentUser().uid, + displayName: getCurrentUser().displayName, + version: oc_userconfig.avatar.version, + isGenerated: oc_userconfig.avatar.generated, + validMimeTypes: VALID_MIME_TYPES, + cropperOptions: { + aspectRatio: 1 / 1, + viewMode: 1, + guides: false, + center: false, + highlight: false, + autoCropArea: 1, + minContainerWidth: 300, + minContainerHeight: 300, + }, + } + }, + + created() { + subscribe('settings:display-name:updated', this.handleDisplayNameUpdate) + }, + + beforeDestroy() { + unsubscribe('settings:display-name:updated', this.handleDisplayNameUpdate) + }, + + methods: { + activateLocalFilePicker() { + // Set to null so that selecting the same file will trigger the change event + this.$refs.input.value = null + this.$refs.input.click() + }, + + onChange(e) { + this.loading = true + const file = e.target.files[0] + if (!this.validMimeTypes.includes(file.type)) { + showError(t('settings', 'Please select a valid png or jpg file')) + this.cancel() + return + } + + const reader = new FileReader() + reader.onload = (e) => { + this.$refs.cropper.replace(e.target.result) + this.showCropper = true + } + reader.readAsDataURL(file) + }, + + async openFilePicker() { + const path = await picker.pick() + this.loading = true + try { + const { data } = await axios.post(generateUrl('/avatar'), { path }) + if (data.status === 'success') { + this.handleAvatarUpdate(false) + } else if (data.data === 'notsquare') { + const tempAvatar = generateUrl('/avatar/tmp') + '?requesttoken=' + encodeURIComponent(OC.requestToken) + '#' + Math.floor(Math.random() * 1000) + this.$refs.cropper.replace(tempAvatar) + this.showCropper = true + } else { + showError(data.data.message) + this.cancel() + } + } catch (e) { + showError(t('settings', 'Error setting profile picture')) + this.cancel() + } + }, + + saveAvatar() { + this.showCropper = false + this.loading = true + + const canvasData = this.$refs.cropper.getCroppedCanvas() + const scaleFactor = canvasData.width > 512 ? 512 / canvasData.width : 1 + + this.$refs.cropper.scale(scaleFactor, scaleFactor).getCroppedCanvas().toBlob(async (blob) => { + if (blob === null) { + showError(t('settings', 'Error cropping profile picture')) + this.cancel() + return + } + + const formData = new FormData() + formData.append('files[]', blob) + try { + await axios.post(generateUrl('/avatar'), formData) + this.handleAvatarUpdate(false) + } catch (e) { + showError(t('settings', 'Error saving profile picture')) + this.handleAvatarUpdate(this.isGenerated) + } + }) + }, + + async removeAvatar() { + this.loading = true + try { + await axios.delete(generateUrl('/avatar')) + this.handleAvatarUpdate(true) + } catch (e) { + showError(t('settings', 'Error removing profile picture')) + this.handleAvatarUpdate(this.isGenerated) + } + }, + + cancel() { + this.showCropper = false + this.loading = false + }, + + handleAvatarUpdate(isGenerated) { + // Update the avatar version so that avatar update handlers refresh correctly + this.version = oc_userconfig.avatar.version = Date.now() + this.isGenerated = oc_userconfig.avatar.generated = isGenerated + this.loading = false + emit('settings:avatar:updated', oc_userconfig.avatar.version) + }, + + handleDisplayNameUpdate() { + this.version = oc_userconfig.avatar.version + }, + }, +} +</script> + +<style lang="scss" scoped> +section { + grid-row: 1/3; + padding: 10px 10px; +} + +.avatar { + &__container { + margin: calc(var(--default-grid-baseline) * 2) auto 0 auto; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 16px 0; + width: min(100%, 300px); + + span { + color: var(--color-text-lighter); + } + } + + &__preview { + display: flex; + justify-content: center; + align-items: center; + width: 180px; + height: 180px; + } + + &__buttons { + display: flex; + gap: 0 10px; + } + + &__cropper { + width: 300px; + height: 300px; + overflow: hidden; + + &-buttons { + width: 100%; + display: flex; + justify-content: space-between; + } + + :deep(.cropper-view-box) { + border-radius: 50%; + } + } +} + +input[type="file"] { + display: none; +} +</style> diff --git a/apps/settings/src/components/PersonalInfo/BiographySection.vue b/apps/settings/src/components/PersonalInfo/BiographySection.vue new file mode 100644 index 00000000000..bbfb25e25cc --- /dev/null +++ b/apps/settings/src/components/PersonalInfo/BiographySection.vue @@ -0,0 +1,34 @@ +<!-- + - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <AccountPropertySection v-bind.sync="biography" + :placeholder="t('settings', 'Your biography. Markdown is supported.')" + :multi-line="true" /> +</template> + +<script> +import { loadState } from '@nextcloud/initial-state' + +import AccountPropertySection from './shared/AccountPropertySection.vue' + +import { NAME_READABLE_ENUM } from '../../constants/AccountPropertyConstants.js' + +const { biography } = loadState('settings', 'personalInfoParameters', {}) + +export default { + name: 'BiographySection', + + components: { + AccountPropertySection, + }, + + data() { + return { + biography: { ...biography, readable: NAME_READABLE_ENUM[biography.name] }, + } + }, +} +</script> diff --git a/apps/settings/src/components/PersonalInfo/BiographySection/Biography.vue b/apps/settings/src/components/PersonalInfo/BiographySection/Biography.vue deleted file mode 100644 index ffe0029db8d..00000000000 --- a/apps/settings/src/components/PersonalInfo/BiographySection/Biography.vue +++ /dev/null @@ -1,183 +0,0 @@ -<!-- - - @copyright 2021, Christopher Ng <chrng8@gmail.com> - - - - @author Christopher Ng <chrng8@gmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - ---> - -<template> - <div class="biography"> - <textarea id="biography" - :placeholder="t('settings', 'Your biography')" - :value="biography" - rows="8" - autocapitalize="none" - autocomplete="off" - autocorrect="off" - @input="onBiographyChange" /> - - <div class="biography__actions-container"> - <transition name="fade"> - <span v-if="showCheckmarkIcon" class="icon-checkmark" /> - <span v-else-if="showErrorIcon" class="icon-error" /> - </transition> - </div> - </div> -</template> - -<script> -import { showError } from '@nextcloud/dialogs' -import { emit } from '@nextcloud/event-bus' -import debounce from 'debounce' - -import { ACCOUNT_PROPERTY_ENUM } from '../../../constants/AccountPropertyConstants' -import { savePrimaryAccountProperty } from '../../../service/PersonalInfo/PersonalInfoService' - -export default { - name: 'Biography', - - props: { - biography: { - type: String, - required: true, - }, - scope: { - type: String, - required: true, - }, - }, - - data() { - return { - initialBiography: this.biography, - localScope: this.scope, - showCheckmarkIcon: false, - showErrorIcon: false, - } - }, - - methods: { - onBiographyChange(e) { - this.$emit('update:biography', e.target.value) - this.debounceBiographyChange(e.target.value.trim()) - }, - - debounceBiographyChange: debounce(async function(biography) { - await this.updatePrimaryBiography(biography) - }, 500), - - async updatePrimaryBiography(biography) { - try { - const responseData = await savePrimaryAccountProperty(ACCOUNT_PROPERTY_ENUM.BIOGRAPHY, biography) - this.handleResponse({ - biography, - status: responseData.ocs?.meta?.status, - }) - } catch (e) { - this.handleResponse({ - errorMessage: t('settings', 'Unable to update biography'), - error: e, - }) - } - }, - - handleResponse({ biography, status, errorMessage, error }) { - if (status === 'ok') { - // Ensure that local state reflects server state - this.initialBiography = biography - emit('settings:biography:updated', biography) - this.showCheckmarkIcon = true - setTimeout(() => { this.showCheckmarkIcon = false }, 2000) - } else { - showError(errorMessage) - this.logger.error(errorMessage, error) - this.showErrorIcon = true - setTimeout(() => { this.showErrorIcon = false }, 2000) - } - }, - - onScopeChange(scope) { - this.$emit('update:scope', scope) - }, - }, -} -</script> - -<style lang="scss" scoped> -.biography { - display: grid; - align-items: center; - - textarea { - resize: vertical; - grid-area: 1 / 1; - width: 100%; - margin: 3px 3px 3px 0; - padding: 7px 6px; - color: var(--color-main-text); - border: 1px solid var(--color-border-dark); - border-radius: var(--border-radius); - background-color: var(--color-main-background); - font-family: var(--font-face); - cursor: text; - - &:hover, - &:focus, - &:active { - border-color: var(--color-primary-element) !important; - outline: none !important; - } - } - - .biography__actions-container { - grid-area: 1 / 1; - justify-self: flex-end; - align-self: flex-end; - height: 30px; - - display: flex; - gap: 0 2px; - margin-right: 5px; - margin-bottom: 5px; - - .icon-checkmark, - .icon-error { - height: 30px !important; - min-height: 30px !important; - width: 30px !important; - min-width: 30px !important; - top: 0; - right: 0; - float: none; - } - } -} - -.fade-enter, -.fade-leave-to { - opacity: 0; -} - -.fade-enter-active { - transition: opacity 200ms ease-out; -} - -.fade-leave-active { - transition: opacity 300ms ease-out; -} -</style> diff --git a/apps/settings/src/components/PersonalInfo/BiographySection/BiographySection.vue b/apps/settings/src/components/PersonalInfo/BiographySection/BiographySection.vue deleted file mode 100644 index c8aacb03e9c..00000000000 --- a/apps/settings/src/components/PersonalInfo/BiographySection/BiographySection.vue +++ /dev/null @@ -1,69 +0,0 @@ -<!-- - - @copyright 2021, Christopher Ng <chrng8@gmail.com> - - - - @author Christopher Ng <chrng8@gmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - ---> - -<template> - <section> - <HeaderBar :account-property="accountProperty" - label-for="biography" - :scope.sync="primaryBiography.scope" /> - - <Biography :biography.sync="primaryBiography.value" - :scope.sync="primaryBiography.scope" /> - </section> -</template> - -<script> -import { loadState } from '@nextcloud/initial-state' - -import Biography from './Biography' -import HeaderBar from '../shared/HeaderBar' - -import { ACCOUNT_PROPERTY_READABLE_ENUM } from '../../../constants/AccountPropertyConstants' - -const { biographyMap: { primaryBiography } } = loadState('settings', 'personalInfoParameters', {}) - -export default { - name: 'BiographySection', - - components: { - Biography, - HeaderBar, - }, - - data() { - return { - accountProperty: ACCOUNT_PROPERTY_READABLE_ENUM.BIOGRAPHY, - primaryBiography, - } - }, -} -</script> - -<style lang="scss" scoped> -section { - padding: 10px 10px; - - &::v-deep button:disabled { - cursor: default; - } -} -</style> diff --git a/apps/settings/src/components/PersonalInfo/BirthdaySection.vue b/apps/settings/src/components/PersonalInfo/BirthdaySection.vue new file mode 100644 index 00000000000..f55f09c95e5 --- /dev/null +++ b/apps/settings/src/components/PersonalInfo/BirthdaySection.vue @@ -0,0 +1,132 @@ +<!-- + - SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> +<template> + <section> + <HeaderBar :scope="birthdate.scope" + :input-id="inputId" + :readable="birthdate.readable" /> + + <NcDateTimePickerNative :id="inputId" + type="date" + label="" + :value="value" + @input="onInput" /> + + <p class="property__helper-text-message"> + {{ t('settings', 'Enter your date of birth') }} + </p> + </section> +</template> + +<script> +import { loadState } from '@nextcloud/initial-state' +import { NAME_READABLE_ENUM } from '../../constants/AccountPropertyConstants.js' +import { savePrimaryAccountProperty } from '../../service/PersonalInfo/PersonalInfoService' +import { handleError } from '../../utils/handlers' + +import debounce from 'debounce' + +import NcDateTimePickerNative from '@nextcloud/vue/components/NcDateTimePickerNative' +import HeaderBar from './shared/HeaderBar.vue' + +const { birthdate } = loadState('settings', 'personalInfoParameters', {}) + +export default { + name: 'BirthdaySection', + + components: { + NcDateTimePickerNative, + HeaderBar, + }, + + data() { + let initialValue = null + if (birthdate.value) { + initialValue = new Date(birthdate.value) + } + + return { + birthdate: { + ...birthdate, + readable: NAME_READABLE_ENUM[birthdate.name], + }, + initialValue, + } + }, + + computed: { + inputId() { + return `account-property-${birthdate.name}` + }, + value: { + get() { + return new Date(this.birthdate.value) + }, + /** @param {Date} value The date to set */ + set(value) { + const day = value.getDate().toString().padStart(2, '0') + const month = (value.getMonth() + 1).toString().padStart(2, '0') + const year = value.getFullYear() + this.birthdate.value = `${year}-${month}-${day}` + }, + }, + }, + + methods: { + onInput(e) { + this.value = e + this.debouncePropertyChange(this.value) + }, + + debouncePropertyChange: debounce(async function(value) { + await this.updateProperty(value) + }, 500), + + async updateProperty(value) { + try { + const responseData = await savePrimaryAccountProperty( + this.birthdate.name, + value, + ) + this.handleResponse({ + value, + status: responseData.ocs?.meta?.status, + }) + } catch (error) { + this.handleResponse({ + errorMessage: t('settings', 'Unable to update date of birth'), + error, + }) + } + }, + + handleResponse({ value, status, errorMessage, error }) { + if (status === 'ok') { + this.initialValue = value + } else { + this.$emit('update:value', this.initialValue) + handleError(error, errorMessage) + } + }, + }, +} +</script> + +<style lang="scss" scoped> +section { + padding: 10px 10px; + + :deep(button:disabled) { + cursor: default; + } + + .property__helper-text-message { + color: var(--color-text-maxcontrast); + padding: 4px 0; + display: flex; + align-items: center; + } +} +</style> diff --git a/apps/settings/src/components/PersonalInfo/BlueskySection.vue b/apps/settings/src/components/PersonalInfo/BlueskySection.vue new file mode 100644 index 00000000000..65223d1ab53 --- /dev/null +++ b/apps/settings/src/components/PersonalInfo/BlueskySection.vue @@ -0,0 +1,64 @@ +<!-- + - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <AccountPropertySection v-bind.sync="value" + :readable="readable" + :on-validate="onValidate" + :placeholder="t('settings', 'Bluesky handle')" /> +</template> + +<script setup lang="ts"> +import type { AccountProperties } from '../../constants/AccountPropertyConstants.js' + +import { loadState } from '@nextcloud/initial-state' +import { t } from '@nextcloud/l10n' +import { ref } from 'vue' +import { NAME_READABLE_ENUM } from '../../constants/AccountPropertyConstants.ts' +import AccountPropertySection from './shared/AccountPropertySection.vue' + +const { bluesky } = loadState<AccountProperties>('settings', 'personalInfoParameters') + +const value = ref({ ...bluesky }) +const readable = NAME_READABLE_ENUM[bluesky.name] + +/** + * Validate that the text might be a bluesky handle + * @param text The potential bluesky handle + */ +function onValidate(text: string): boolean { + if (text === '') return true + + const lowerText = text.toLowerCase() + + if (lowerText === 'bsky.social') { + // Standalone bsky.social is invalid + return false + } + + if (lowerText.endsWith('.bsky.social')) { + // Enforce format: exactly one label + '.bsky.social' + const parts = lowerText.split('.') + + // Must be in form: [username, 'bsky', 'social'] + if (parts.length !== 3 || parts[1] !== 'bsky' || parts[2] !== 'social') { + return false + } + + const username = parts[0] + const validateRegex = /^[a-z0-9][a-z0-9-]{2,17}$/ + return validateRegex.test(username) + } + + // Else, treat as a custom domain + try { + const url = new URL(`https://${text}`) + // Ensure the parsed host matches exactly (case-insensitive already) + return url.host === lowerText + } catch { + return false + } +} +</script> diff --git a/apps/settings/src/components/PersonalInfo/DetailsSection.vue b/apps/settings/src/components/PersonalInfo/DetailsSection.vue new file mode 100644 index 00000000000..d4bb0ce16ec --- /dev/null +++ b/apps/settings/src/components/PersonalInfo/DetailsSection.vue @@ -0,0 +1,114 @@ +<!-- + - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <section> + <HeaderBar :is-heading="true" :readable="t('settings', 'Details')" /> + + <div class="details"> + <div class="details__groups"> + <Account :size="20" /> + <div class="details__groups-info"> + <p>{{ t('settings', 'You are a member of the following groups:') }}</p> + <p class="details__groups-list"> + {{ groups.join(', ') }} + </p> + </div> + </div> + <div class="details__quota"> + <CircleSlice :size="20" /> + <div class="details__quota-info"> + <!-- eslint-disable-next-line vue/no-v-html --> + <p class="details__quota-text" v-html="quotaText" /> + <NcProgressBar size="medium" + :value="usageRelative" + :error="usageRelative > 80" /> + </div> + </div> + </div> + </section> +</template> + +<script> +import { loadState } from '@nextcloud/initial-state' +import { t } from '@nextcloud/l10n' + +import NcProgressBar from '@nextcloud/vue/components/NcProgressBar' +import Account from 'vue-material-design-icons/AccountOutline.vue' +import CircleSlice from 'vue-material-design-icons/CircleSlice3.vue' + +import HeaderBar from './shared/HeaderBar.vue' + +/** SYNC to be kept in sync with `lib/public/Files/FileInfo.php` */ +const SPACE_UNLIMITED = -3 + +const { groups, quota, totalSpace, usage, usageRelative } = loadState('settings', 'personalInfoParameters', {}) + +export default { + name: 'DetailsSection', + + components: { + Account, + CircleSlice, + HeaderBar, + NcProgressBar, + }, + + data() { + return { + groups, + usageRelative, + } + }, + + computed: { + quotaText() { + if (quota === SPACE_UNLIMITED) { + return t('settings', 'You are using {s}{usage}{/s}', { usage, s: '<strong>', '/s': '</strong>' }, undefined, { escape: false }) + } + return t( + 'settings', + 'You are using {s}{usage}{/s} of {s}{totalSpace}{/s} ({s}{usageRelative}%{/s})', + { usage, totalSpace, usageRelative, s: '<strong>', '/s': '</strong>' }, + undefined, + { escape: false }, + ) + }, + }, +} +</script> + +<style lang="scss" scoped> +.details { + display: flex; + flex-direction: column; + margin-block: 10px; + margin-inline: 0 32px; + gap: 16px 0; + color: var(--color-text-maxcontrast); + + &__groups, + &__quota { + display: flex; + gap: 0 10px; + + &-info { + display: flex; + flex-direction: column; + width: 100%; + gap: 4px 0; + } + + &-list { + font-weight: bold; + } + + &:deep(.material-design-icon) { + align-self: flex-start; + margin-top: 2px; + } + } +} +</style> diff --git a/apps/settings/src/components/PersonalInfo/DisplayNameSection.vue b/apps/settings/src/components/PersonalInfo/DisplayNameSection.vue new file mode 100644 index 00000000000..431dfbecc9a --- /dev/null +++ b/apps/settings/src/components/PersonalInfo/DisplayNameSection.vue @@ -0,0 +1,54 @@ +<!-- + - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <AccountPropertySection v-bind.sync="displayName" + :placeholder="t('settings', 'Your full name')" + autocomplete="username" + :is-editable="displayNameChangeSupported" + :on-validate="onValidate" + :on-save="onSave" /> +</template> + +<script> +import { loadState } from '@nextcloud/initial-state' +import { emit } from '@nextcloud/event-bus' + +import AccountPropertySection from './shared/AccountPropertySection.vue' + +import { NAME_READABLE_ENUM } from '../../constants/AccountPropertyConstants.js' + +const { displayName } = loadState('settings', 'personalInfoParameters', {}) +const { displayNameChangeSupported } = loadState('settings', 'accountParameters', {}) + +export default { + name: 'DisplayNameSection', + + components: { + AccountPropertySection, + }, + + data() { + return { + displayName: { ...displayName, readable: NAME_READABLE_ENUM[displayName.name] }, + displayNameChangeSupported, + } + }, + + methods: { + onValidate(value) { + return value !== '' + }, + + onSave(value) { + if (oc_userconfig.avatar.generated) { + // Update the avatar version so that avatar update handlers refresh correctly + oc_userconfig.avatar.version = Date.now() + } + emit('settings:display-name:updated', value) + }, + }, +} +</script> diff --git a/apps/settings/src/components/PersonalInfo/DisplayNameSection/DisplayName.vue b/apps/settings/src/components/PersonalInfo/DisplayNameSection/DisplayName.vue deleted file mode 100644 index 0cfa630123e..00000000000 --- a/apps/settings/src/components/PersonalInfo/DisplayNameSection/DisplayName.vue +++ /dev/null @@ -1,179 +0,0 @@ -<!-- - - @copyright 2021, Christopher Ng <chrng8@gmail.com> - - - - @author Christopher Ng <chrng8@gmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - ---> - -<template> - <div class="displayname"> - <input id="displayname" - type="text" - :placeholder="t('settings', 'Your full name')" - :value="displayName" - autocapitalize="none" - autocomplete="on" - autocorrect="off" - @input="onDisplayNameChange"> - - <div class="displayname__actions-container"> - <transition name="fade"> - <span v-if="showCheckmarkIcon" class="icon-checkmark" /> - <span v-else-if="showErrorIcon" class="icon-error" /> - </transition> - </div> - </div> -</template> - -<script> -import { showError } from '@nextcloud/dialogs' -import { emit } from '@nextcloud/event-bus' -import debounce from 'debounce' - -import { ACCOUNT_PROPERTY_ENUM } from '../../../constants/AccountPropertyConstants' -import { savePrimaryAccountProperty } from '../../../service/PersonalInfo/PersonalInfoService' -import { validateStringInput } from '../../../utils/validate' - -// TODO Global avatar updating on events (e.g. updating the displayname) is currently being handled by global js, investigate using https://github.com/nextcloud/nextcloud-event-bus for global avatar updating - -export default { - name: 'DisplayName', - - props: { - displayName: { - type: String, - required: true, - }, - scope: { - type: String, - required: true, - }, - }, - - data() { - return { - initialDisplayName: this.displayName, - localScope: this.scope, - showCheckmarkIcon: false, - showErrorIcon: false, - } - }, - - methods: { - onDisplayNameChange(e) { - this.$emit('update:display-name', e.target.value) - this.debounceDisplayNameChange(e.target.value.trim()) - }, - - debounceDisplayNameChange: debounce(async function(displayName) { - if (validateStringInput(displayName)) { - await this.updatePrimaryDisplayName(displayName) - } - }, 500), - - async updatePrimaryDisplayName(displayName) { - try { - const responseData = await savePrimaryAccountProperty(ACCOUNT_PROPERTY_ENUM.DISPLAYNAME, displayName) - this.handleResponse({ - displayName, - status: responseData.ocs?.meta?.status, - }) - } catch (e) { - this.handleResponse({ - errorMessage: t('settings', 'Unable to update full name'), - error: e, - }) - } - }, - - handleResponse({ displayName, status, errorMessage, error }) { - if (status === 'ok') { - // Ensure that local state reflects server state - this.initialDisplayName = displayName - emit('settings:display-name:updated', displayName) - this.showCheckmarkIcon = true - setTimeout(() => { this.showCheckmarkIcon = false }, 2000) - } else { - showError(errorMessage) - this.logger.error(errorMessage, error) - this.showErrorIcon = true - setTimeout(() => { this.showErrorIcon = false }, 2000) - } - }, - - onScopeChange(scope) { - this.$emit('update:scope', scope) - }, - }, -} -</script> - -<style lang="scss" scoped> -.displayname { - display: grid; - align-items: center; - - input { - grid-area: 1 / 1; - width: 100%; - height: 34px; - margin: 3px 3px 3px 0; - padding: 7px 6px; - color: var(--color-main-text); - border: 1px solid var(--color-border-dark); - border-radius: var(--border-radius); - background-color: var(--color-main-background); - font-family: var(--font-face); - cursor: text; - } - - .displayname__actions-container { - grid-area: 1 / 1; - justify-self: flex-end; - height: 30px; - - display: flex; - gap: 0 2px; - margin-right: 5px; - - .icon-checkmark, - .icon-error { - height: 30px !important; - min-height: 30px !important; - width: 30px !important; - min-width: 30px !important; - top: 0; - right: 0; - float: none; - } - } -} - -.fade-enter, -.fade-leave-to { - opacity: 0; -} - -.fade-enter-active { - transition: opacity 200ms ease-out; -} - -.fade-leave-active { - transition: opacity 300ms ease-out; -} -</style> diff --git a/apps/settings/src/components/PersonalInfo/DisplayNameSection/DisplayNameSection.vue b/apps/settings/src/components/PersonalInfo/DisplayNameSection/DisplayNameSection.vue deleted file mode 100644 index caee7e7c68e..00000000000 --- a/apps/settings/src/components/PersonalInfo/DisplayNameSection/DisplayNameSection.vue +++ /dev/null @@ -1,86 +0,0 @@ -<!-- - - @copyright 2021, Christopher Ng <chrng8@gmail.com> - - - - @author Christopher Ng <chrng8@gmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - ---> - -<template> - <section> - <HeaderBar :account-property="accountProperty" - label-for="displayname" - :is-editable="displayNameChangeSupported" - :is-valid-section="isValidSection" - :scope.sync="primaryDisplayName.scope" /> - - <template v-if="displayNameChangeSupported"> - <DisplayName :display-name.sync="primaryDisplayName.value" - :scope.sync="primaryDisplayName.scope" /> - </template> - - <span v-else> - {{ primaryDisplayName.value || t('settings', 'No full name set') }} - </span> - </section> -</template> - -<script> -import { loadState } from '@nextcloud/initial-state' - -import DisplayName from './DisplayName' -import HeaderBar from '../shared/HeaderBar' - -import { ACCOUNT_PROPERTY_READABLE_ENUM } from '../../../constants/AccountPropertyConstants' -import { validateStringInput } from '../../../utils/validate' - -const { displayNameMap: { primaryDisplayName } } = loadState('settings', 'personalInfoParameters', {}) -const { displayNameChangeSupported } = loadState('settings', 'accountParameters', {}) - -export default { - name: 'DisplayNameSection', - - components: { - DisplayName, - HeaderBar, - }, - - data() { - return { - accountProperty: ACCOUNT_PROPERTY_READABLE_ENUM.DISPLAYNAME, - displayNameChangeSupported, - primaryDisplayName, - } - }, - - computed: { - isValidSection() { - return validateStringInput(this.primaryDisplayName.value) - }, - }, -} -</script> - -<style lang="scss" scoped> -section { - padding: 10px 10px; - - &::v-deep button:disabled { - cursor: default; - } -} -</style> diff --git a/apps/settings/src/components/PersonalInfo/EmailSection/Email.vue b/apps/settings/src/components/PersonalInfo/EmailSection/Email.vue index ef03ae0677d..6a6baef8817 100644 --- a/apps/settings/src/components/PersonalInfo/EmailSection/Email.vue +++ b/apps/settings/src/components/PersonalInfo/EmailSection/Email.vue @@ -1,74 +1,59 @@ <!-- - - @copyright 2021, Christopher Ng <chrng8@gmail.com> - - - - @author Christopher Ng <chrng8@gmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - + - SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later --> <template> <div> - <div class="email"> - <input :id="inputId" - ref="email" - type="email" - :placeholder="inputPlaceholder" - :value="email" - autocapitalize="none" - autocomplete="on" - autocorrect="off" - @input="onEmailChange"> - - <div class="email__actions-container"> - <transition name="fade"> - <span v-if="showCheckmarkIcon" class="icon-checkmark" /> - <span v-else-if="showErrorIcon" class="icon-error" /> - </transition> - - <template v-if="!primary"> - <FederationControl :account-property="accountProperty" - :additional="true" - :additional-value="email" - :disabled="federationDisabled" - :handle-additional-scope-change="saveAdditionalEmailScope" - :scope.sync="localScope" - @update:scope="onScopeChange" /> - </template> - - <Actions class="email__actions" - :aria-label="t('settings', 'Email options')" - :disabled="deleteDisabled" - :force-menu="true"> - <ActionButton :aria-label="deleteEmailLabel" - :close-after-click="true" - :disabled="deleteDisabled" - icon="icon-delete" - @click.stop.prevent="deleteEmail"> - {{ deleteEmailLabel }} - </ActionButton> - <ActionButton v-if="!primary || !isNotificationEmail" - :aria-label="setNotificationMailLabel" - :close-after-click="true" - :disabled="setNotificationMailDisabled" - icon="icon-favorite" - @click.stop.prevent="setNotificationMail"> - {{ setNotificationMailLabel }} - </ActionButton> - </Actions> + <div class="email" :class="{ 'email--additional': !primary }"> + <div v-if="!primary" class="email__label-container"> + <label :for="inputIdWithDefault">{{ inputPlaceholder }}</label> + <FederationControl v-if="!federationDisabled && !primary" + :readable="propertyReadable" + :additional="true" + :additional-value="email" + :disabled="federationDisabled" + :handle-additional-scope-change="saveAdditionalEmailScope" + :scope.sync="localScope" + @update:scope="onScopeChange" /> + </div> + <div class="email__input-container"> + <NcTextField :id="inputIdWithDefault" + ref="email" + class="email__input" + autocapitalize="none" + autocomplete="email" + :error="hasError || !!helperText" + :helper-text="helperTextWithNonConfirmed" + label-outside + :placeholder="inputPlaceholder" + spellcheck="false" + :success="isSuccess" + type="email" + :value.sync="emailAddress" /> + + <div class="email__actions"> + <NcActions :aria-label="actionsLabel"> + <NcActionButton v-if="!primary || !isNotificationEmail" + close-after-click + :disabled="!isConfirmedAddress" + @click="setNotificationMail"> + <template #icon> + <NcIconSvgWrapper v-if="isNotificationEmail" :path="mdiStar" /> + <NcIconSvgWrapper v-else :path="mdiStarOutline" /> + </template> + {{ setNotificationMailLabel }} + </NcActionButton> + <NcActionButton close-after-click + :disabled="deleteDisabled" + @click="deleteEmail"> + <template #icon> + <NcIconSvgWrapper :path="mdiTrashCanOutline" /> + </template> + {{ deleteEmailLabel }} + </NcActionButton> + </NcActions> + </div> </div> </div> @@ -79,14 +64,19 @@ </template> <script> -import Actions from '@nextcloud/vue/dist/Components/Actions' -import ActionButton from '@nextcloud/vue/dist/Components/ActionButton' -import { showError } from '@nextcloud/dialogs' +import NcActions from '@nextcloud/vue/components/NcActions' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' +import NcTextField from '@nextcloud/vue/components/NcTextField' + import debounce from 'debounce' -import FederationControl from '../shared/FederationControl' +import { mdiArrowLeft, mdiLockOutline, mdiStar, mdiStarOutline, mdiTrashCanOutline } from '@mdi/js' -import { ACCOUNT_PROPERTY_READABLE_ENUM, VERIFICATION_ENUM } from '../../../constants/AccountPropertyConstants' +import FederationControl from '../shared/FederationControl.vue' +import { handleError } from '../../../utils/handlers.ts' + +import { ACCOUNT_PROPERTY_READABLE_ENUM, VERIFICATION_ENUM } from '../../../constants/AccountPropertyConstants.js' import { removeAdditionalEmail, saveAdditionalEmail, @@ -94,15 +84,17 @@ import { saveNotificationEmail, savePrimaryEmail, updateAdditionalEmail, -} from '../../../service/PersonalInfo/EmailService' -import { validateEmail } from '../../../utils/validate' +} from '../../../service/PersonalInfo/EmailService.js' +import { validateEmail } from '../../../utils/validate.js' export default { name: 'Email', components: { - Actions, - ActionButton, + NcActions, + NcActionButton, + NcIconSvgWrapper, + NcTextField, FederationControl, }, @@ -131,20 +123,45 @@ export default { type: Number, default: VERIFICATION_ENUM.NOT_VERIFIED, }, + inputId: { + type: String, + required: false, + default: '', + }, + }, + + setup() { + return { + mdiArrowLeft, + mdiLockOutline, + mdiStar, + mdiStarOutline, + mdiTrashCanOutline, + saveAdditionalEmailScope, + } }, data() { return { - accountProperty: ACCOUNT_PROPERTY_READABLE_ENUM.EMAIL, + hasError: false, + helperText: null, initialEmail: this.email, + isSuccess: false, localScope: this.scope, - saveAdditionalEmailScope, - showCheckmarkIcon: false, - showErrorIcon: false, + propertyReadable: ACCOUNT_PROPERTY_READABLE_ENUM.EMAIL, + showFederationSettings: false, } }, computed: { + actionsLabel() { + if (this.primary) { + return t('settings', 'Email options') + } else { + return t('settings', 'Options for additional email address {index}', { index: this.index + 1 }) + } + }, + deleteDisabled() { if (this.primary) { // Disable for empty primary email as there is nothing to delete @@ -163,15 +180,27 @@ export default { return t('settings', 'Delete email') }, - setNotificationMailDisabled() { - return !this.primary && this.localVerificationState !== VERIFICATION_ENUM.VERIFIED + isConfirmedAddress() { + return this.primary || this.localVerificationState === VERIFICATION_ENUM.VERIFIED }, - setNotificationMailLabel() { + isNotConfirmedHelperText() { + if (!this.isConfirmedAddress) { + return t('settings', 'This address is not confirmed') + } + return '' + }, + + helperTextWithNonConfirmed() { + if (this.helperText || this.hasError || this.isSuccess) { + return this.helperText || '' + } + return this.isNotConfirmedHelperText + }, + + setNotificationMailLabel() { if (this.isNotificationEmail) { return t('settings', 'Unset as primary email') - } else if (!this.primary && this.localVerificationState !== VERIFICATION_ENUM.VERIFIED) { - return t('settings', 'This address is not confirmed') } return t('settings', 'Set as primary email') }, @@ -180,40 +209,46 @@ export default { return !this.initialEmail }, - inputId() { - if (this.primary) { - return 'email' - } - return `email-${this.index}` + inputIdWithDefault() { + return this.inputId || `account-property-email--${this.index}` }, inputPlaceholder() { - if (this.primary) { - return t('settings', 'Your email address') - } - return t('settings', 'Additional email address {index}', { index: this.index + 1 }) + // Primary email has implicit linked <label> + return !this.primary ? t('settings', 'Additional email address {index}', { index: this.index + 1 }) : undefined }, isNotificationEmail() { return (this.email && this.email === this.activeNotificationEmail) || (this.primary && this.activeNotificationEmail === '') }, + + emailAddress: { + get() { + return this.email + }, + set(value) { + this.$emit('update:email', value) + this.debounceEmailChange(value.trim()) + }, + }, }, mounted() { if (!this.primary && this.initialEmail === '') { - // $nextTick is needed here, otherwise it may not always work https://stackoverflow.com/questions/51922767/autofocus-input-on-mount-vue-ios/63485725#63485725 + // $nextTick is needed here, otherwise it may not always work + // https://stackoverflow.com/questions/51922767/autofocus-input-on-mount-vue-ios/63485725#63485725 this.$nextTick(() => this.$refs.email?.focus()) } }, methods: { - onEmailChange(e) { - this.$emit('update:email', e.target.value) - this.debounceEmailChange(e.target.value.trim()) - }, - debounceEmailChange: debounce(async function(email) { + // TODO: provide method to get native input in NcTextField + this.helperText = this.$refs.email.$refs.inputField.$refs.input.validationMessage || null + if (this.helperText !== null) { + return + } if (validateEmail(email) || email === '') { if (this.primary) { await this.updatePrimaryEmail(email) @@ -227,7 +262,7 @@ export default { } } } - }, 500), + }, 1000), async deleteEmail() { if (this.primary) { @@ -321,6 +356,9 @@ export default { handleDeleteAdditionalEmail(status) { if (status === 'ok') { this.$emit('delete-additional-email') + if (this.isNotificationEmail) { + this.$emit('update:notification-email', '') + } } else { this.handleResponse({ errorMessage: t('settings', 'Unable to delete additional email address'), @@ -336,13 +374,12 @@ export default { } else if (notificationEmail !== undefined) { this.$emit('update:notification-email', notificationEmail) } - this.showCheckmarkIcon = true - setTimeout(() => { this.showCheckmarkIcon = false }, 2000) + this.isSuccess = true + setTimeout(() => { this.isSuccess = false }, 2000) } else { - showError(errorMessage) - this.logger.error(errorMessage, error) - this.showErrorIcon = true - setTimeout(() => { this.showErrorIcon = false }, 2000) + handleError(error, errorMessage) + this.hasError = true + setTimeout(() => { this.hasError = false }, 2000) } }, @@ -355,72 +392,29 @@ export default { <style lang="scss" scoped> .email { - display: grid; - align-items: center; - - input { - grid-area: 1 / 1; - width: 100%; - height: 34px; - margin: 3px 3px 3px 0; - padding: 7px 6px; - color: var(--color-main-text); - border: 1px solid var(--color-border-dark); - border-radius: var(--border-radius); - background-color: var(--color-main-background); - font-family: var(--font-face); - cursor: text; - } - - .email__actions-container { - grid-area: 1 / 1; - justify-self: flex-end; - height: 30px; - + &__label-container { + height: var(--default-clickable-area); display: flex; - gap: 0 2px; - margin-right: 5px; - - .email__actions { - opacity: 0.4 !important; - - &:hover, - &:focus, - &:active { - opacity: 0.8 !important; - } + flex-direction: row; + align-items: center; + gap: calc(var(--default-grid-baseline) * 2); + } - &::v-deep button { - height: 30px !important; - min-height: 30px !important; - width: 30px !important; - min-width: 30px !important; - } - } + &__input-container { + position: relative; + } - .icon-checkmark, - .icon-error { - height: 30px !important; - min-height: 30px !important; - width: 30px !important; - min-width: 30px !important; - top: 0; - right: 0; - float: none; + &__input { + // TODO: provide a way to hide status icon or combine it with trailing button in NcInputField + :deep(.input-field__icon--trailing) { + display: none; } } -} - -.fade-enter, -.fade-leave-to { - opacity: 0; -} -.fade-enter-active { - transition: opacity 200ms ease-out; -} - -.fade-leave-active { - transition: opacity 300ms ease-out; + &__actions { + position: absolute; + inset-block-start: 0; + inset-inline-end: 0; + } } </style> diff --git a/apps/settings/src/components/PersonalInfo/EmailSection/EmailSection.vue b/apps/settings/src/components/PersonalInfo/EmailSection/EmailSection.vue index 07ec35861a9..f9674a3163b 100644 --- a/apps/settings/src/components/PersonalInfo/EmailSection/EmailSection.vue +++ b/apps/settings/src/components/PersonalInfo/EmailSection/EmailSection.vue @@ -1,38 +1,21 @@ <!-- - - @copyright 2021, Christopher Ng <chrng8@gmail.com> - - - - @author Christopher Ng <chrng8@gmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - + - SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later --> <template> - <section> - <HeaderBar :account-property="accountProperty" - label-for="email" - :handle-scope-change="savePrimaryEmailScope" + <section class="section-emails"> + <HeaderBar :input-id="inputId" + :readable="primaryEmail.readable" :is-editable="true" :is-multi-value-supported="true" :is-valid-section="isValidSection" :scope.sync="primaryEmail.scope" @add-additional="onAddAdditionalEmail" /> - <template v-if="displayNameChangeSupported"> - <Email :primary="true" + <template v-if="emailChangeSupported"> + <Email :input-id="inputId" + :primary="true" :scope.sync="primaryEmail.scope" :email.sync="primaryEmail.value" :active-notification-email.sync="notificationEmail" @@ -45,9 +28,10 @@ </span> <template v-if="additionalEmails.length"> - <em class="additional-emails-label">{{ t('settings', 'Additional emails') }}</em> + <!-- TODO use unique key for additional email when uniqueness can be guaranteed, see https://github.com/nextcloud/server/issues/26866 --> <Email v-for="(additionalEmail, index) in additionalEmails" - :key="index" + :key="additionalEmail.key" + class="section-emails__additional-email" :index="index" :scope.sync="additionalEmail.scope" :email.sync="additionalEmail.value" @@ -62,17 +46,17 @@ <script> import { loadState } from '@nextcloud/initial-state' -import { showError } from '@nextcloud/dialogs' -import Email from './Email' -import HeaderBar from '../shared/HeaderBar' +import Email from './Email.vue' +import HeaderBar from '../shared/HeaderBar.vue' -import { ACCOUNT_PROPERTY_READABLE_ENUM, DEFAULT_ADDITIONAL_EMAIL_SCOPE } from '../../../constants/AccountPropertyConstants' -import { savePrimaryEmail, savePrimaryEmailScope, removeAdditionalEmail } from '../../../service/PersonalInfo/EmailService' -import { validateEmail } from '../../../utils/validate' +import { ACCOUNT_PROPERTY_READABLE_ENUM, DEFAULT_ADDITIONAL_EMAIL_SCOPE, NAME_READABLE_ENUM } from '../../../constants/AccountPropertyConstants.js' +import { savePrimaryEmail, removeAdditionalEmail } from '../../../service/PersonalInfo/EmailService.js' +import { validateEmail } from '../../../utils/validate.js' +import { handleError } from '../../../utils/handlers.ts' const { emailMap: { additionalEmails, primaryEmail, notificationEmail } } = loadState('settings', 'personalInfoParameters', {}) -const { displayNameChangeSupported } = loadState('settings', 'accountParameters', {}) +const { emailChangeSupported } = loadState('settings', 'accountParameters', {}) export default { name: 'EmailSection', @@ -85,10 +69,9 @@ export default { data() { return { accountProperty: ACCOUNT_PROPERTY_READABLE_ENUM.EMAIL, - additionalEmails, - displayNameChangeSupported, - primaryEmail, - savePrimaryEmailScope, + additionalEmails: additionalEmails.map(properties => ({ ...properties, key: this.generateUniqueKey() })), + emailChangeSupported, + primaryEmail: { ...primaryEmail, readable: NAME_READABLE_ENUM[primaryEmail.name] }, notificationEmail, } }, @@ -101,6 +84,10 @@ export default { return null }, + inputId() { + return `account-property-${this.primaryEmail.name}` + }, + isValidSection() { return validateEmail(this.primaryEmail.value) && this.additionalEmails.map(({ value }) => value).every(validateEmail) @@ -119,7 +106,7 @@ export default { methods: { onAddAdditionalEmail() { if (this.isValidSection) { - this.additionalEmails.push({ value: '', scope: DEFAULT_ADDITIONAL_EMAIL_SCOPE }) + this.additionalEmails.push({ value: '', scope: DEFAULT_ADDITIONAL_EMAIL_SCOPE, key: this.generateUniqueKey() }) } }, @@ -148,7 +135,7 @@ export default { this.handleResponse( 'error', t('settings', 'Unable to update primary email address'), - e + e, ) } }, @@ -161,7 +148,7 @@ export default { this.handleResponse( 'error', t('settings', 'Unable to delete additional email address'), - e + e, ) } }, @@ -173,32 +160,30 @@ export default { this.handleResponse( 'error', t('settings', 'Unable to delete additional email address'), - {} + {}, ) } }, handleResponse(status, errorMessage, error) { if (status !== 'ok') { - showError(errorMessage) - this.logger.error(errorMessage, error) + handleError(error, errorMessage) } }, + + generateUniqueKey() { + return Math.random().toString(36).substring(2) + }, }, } </script> <style lang="scss" scoped> -section { +.section-emails { padding: 10px 10px; - &::v-deep button:disabled { - cursor: default; - } - - .additional-emails-label { - display: block; - margin-top: 16px; + &__additional-email { + margin-top: calc(var(--default-grid-baseline) * 3); } } </style> diff --git a/apps/settings/src/components/PersonalInfo/FediverseSection.vue b/apps/settings/src/components/PersonalInfo/FediverseSection.vue new file mode 100644 index 00000000000..043fa6e64b9 --- /dev/null +++ b/apps/settings/src/components/PersonalInfo/FediverseSection.vue @@ -0,0 +1,50 @@ +<!-- + - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <AccountPropertySection v-bind.sync="value" + :readable="readable" + :on-validate="onValidate" + :placeholder="t('settings', 'Your handle')" /> +</template> + +<script setup lang="ts"> +import type { AccountProperties } from '../../constants/AccountPropertyConstants.js' +import { loadState } from '@nextcloud/initial-state' +import { t } from '@nextcloud/l10n' +import { ref } from 'vue' +import { NAME_READABLE_ENUM } from '../../constants/AccountPropertyConstants.js' + +import AccountPropertySection from './shared/AccountPropertySection.vue' + +const { fediverse } = loadState<AccountProperties>('settings', 'personalInfoParameters') + +const value = ref({ ...fediverse }) +const readable = NAME_READABLE_ENUM[fediverse.name] + +/** + * Validate a fediverse handle + * @param text The potential fediverse handle + */ +function onValidate(text: string): boolean { + // allow to clear the value + if (text === '') { + return true + } + + // check its in valid format + const result = text.match(/^@?([^@/]+)@([^@/]+)$/) + if (result === null) { + return false + } + + // check its a valid URL + try { + return URL.parse(`https://${result[2]}/`) !== null + } catch { + return false + } +} +</script> diff --git a/apps/settings/src/components/PersonalInfo/FirstDayOfWeekSection.vue b/apps/settings/src/components/PersonalInfo/FirstDayOfWeekSection.vue new file mode 100644 index 00000000000..98501db7ccc --- /dev/null +++ b/apps/settings/src/components/PersonalInfo/FirstDayOfWeekSection.vue @@ -0,0 +1,126 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later + --> + +<template> + <section class="fdow-section"> + <HeaderBar :input-id="inputId" + :readable="propertyReadable" /> + + <NcSelect :aria-label-listbox="t('settings', 'Day to use as the first day of week')" + class="fdow-section__day-select" + :clearable="false" + :input-id="inputId" + label="label" + label-outside + :options="dayOptions" + :value="valueOption" + @option:selected="updateFirstDayOfWeek" /> + </section> +</template> + +<script lang="ts"> +import HeaderBar from './shared/HeaderBar.vue' +import NcSelect from '@nextcloud/vue/components/NcSelect' +import { + ACCOUNT_SETTING_PROPERTY_ENUM, + ACCOUNT_SETTING_PROPERTY_READABLE_ENUM, +} from '../../constants/AccountPropertyConstants' +import { getDayNames, getFirstDay } from '@nextcloud/l10n' +import { savePrimaryAccountProperty } from '../../service/PersonalInfo/PersonalInfoService' +import { handleError } from '../../utils/handlers.ts' +import { loadState } from '@nextcloud/initial-state' + +interface DayOption { + value: number, + label: string, +} + +const { firstDayOfWeek } = loadState<{firstDayOfWeek?: string}>( + 'settings', + 'personalInfoParameters', + {}, +) + +export default { + name: 'FirstDayOfWeekSection', + components: { + HeaderBar, + NcSelect, + }, + data() { + let firstDay = -1 + if (firstDayOfWeek) { + firstDay = parseInt(firstDayOfWeek) + } + + return { + firstDay, + } + }, + computed: { + inputId(): string { + return 'account-property-fdow' + }, + propertyReadable(): string { + return ACCOUNT_SETTING_PROPERTY_READABLE_ENUM.FIRST_DAY_OF_WEEK + }, + dayOptions(): DayOption[] { + const options = [{ + value: -1, + label: t('settings', 'Derived from your locale ({weekDayName})', { + weekDayName: getDayNames()[getFirstDay()], + }), + }] + for (const [index, dayName] of getDayNames().entries()) { + options.push({ value: index, label: dayName }) + } + return options + }, + valueOption(): DayOption | undefined { + return this.dayOptions.find((option) => option.value === this.firstDay) + }, + }, + methods: { + async updateFirstDayOfWeek(option: DayOption): Promise<void> { + try { + const responseData = await savePrimaryAccountProperty( + ACCOUNT_SETTING_PROPERTY_ENUM.FIRST_DAY_OF_WEEK, + option.value.toString(), + ) + this.handleResponse({ + value: option.value, + status: responseData.ocs?.meta?.status, + }) + window.location.reload() + } catch (e) { + this.handleResponse({ + errorMessage: t('settings', 'Unable to update first day of week'), + error: e, + }) + } + }, + + handleResponse({ value, status, errorMessage, error }): void { + if (status === 'ok') { + this.firstDay = value + } else { + this.$emit('update:value', this.firstDay) + handleError(error, errorMessage) + } + }, + }, +} +</script> + +<style lang="scss" scoped> +.fdow-section { + padding: 10px; + + &__day-select { + width: 100%; + margin-top: 6px; // align with other inputs + } +} +</style> diff --git a/apps/settings/src/components/PersonalInfo/HeadlineSection.vue b/apps/settings/src/components/PersonalInfo/HeadlineSection.vue new file mode 100644 index 00000000000..25fbde5b2f5 --- /dev/null +++ b/apps/settings/src/components/PersonalInfo/HeadlineSection.vue @@ -0,0 +1,33 @@ +<!-- + - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <AccountPropertySection v-bind.sync="headline" + :placeholder="t('settings', 'Your headline')" /> +</template> + +<script> +import { loadState } from '@nextcloud/initial-state' + +import AccountPropertySection from './shared/AccountPropertySection.vue' + +import { NAME_READABLE_ENUM } from '../../constants/AccountPropertyConstants.js' + +const { headline } = loadState('settings', 'personalInfoParameters', {}) + +export default { + name: 'HeadlineSection', + + components: { + AccountPropertySection, + }, + + data() { + return { + headline: { ...headline, readable: NAME_READABLE_ENUM[headline.name] }, + } + }, +} +</script> diff --git a/apps/settings/src/components/PersonalInfo/HeadlineSection/Headline.vue b/apps/settings/src/components/PersonalInfo/HeadlineSection/Headline.vue deleted file mode 100644 index 2b81169bb4b..00000000000 --- a/apps/settings/src/components/PersonalInfo/HeadlineSection/Headline.vue +++ /dev/null @@ -1,174 +0,0 @@ -<!-- - - @copyright 2021, Christopher Ng <chrng8@gmail.com> - - - - @author Christopher Ng <chrng8@gmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - ---> - -<template> - <div class="headline"> - <input id="headline" - type="text" - :placeholder="t('settings', 'Your headline')" - :value="headline" - autocapitalize="none" - autocomplete="on" - autocorrect="off" - @input="onHeadlineChange"> - - <div class="headline__actions-container"> - <transition name="fade"> - <span v-if="showCheckmarkIcon" class="icon-checkmark" /> - <span v-else-if="showErrorIcon" class="icon-error" /> - </transition> - </div> - </div> -</template> - -<script> -import { showError } from '@nextcloud/dialogs' -import { emit } from '@nextcloud/event-bus' -import debounce from 'debounce' - -import { ACCOUNT_PROPERTY_ENUM } from '../../../constants/AccountPropertyConstants' -import { savePrimaryAccountProperty } from '../../../service/PersonalInfo/PersonalInfoService' - -export default { - name: 'Headline', - - props: { - headline: { - type: String, - required: true, - }, - scope: { - type: String, - required: true, - }, - }, - - data() { - return { - initialHeadline: this.headline, - localScope: this.scope, - showCheckmarkIcon: false, - showErrorIcon: false, - } - }, - - methods: { - onHeadlineChange(e) { - this.$emit('update:headline', e.target.value) - this.debounceHeadlineChange(e.target.value.trim()) - }, - - debounceHeadlineChange: debounce(async function(headline) { - await this.updatePrimaryHeadline(headline) - }, 500), - - async updatePrimaryHeadline(headline) { - try { - const responseData = await savePrimaryAccountProperty(ACCOUNT_PROPERTY_ENUM.HEADLINE, headline) - this.handleResponse({ - headline, - status: responseData.ocs?.meta?.status, - }) - } catch (e) { - this.handleResponse({ - errorMessage: t('settings', 'Unable to update headline'), - error: e, - }) - } - }, - - handleResponse({ headline, status, errorMessage, error }) { - if (status === 'ok') { - // Ensure that local state reflects server state - this.initialHeadline = headline - emit('settings:headline:updated', headline) - this.showCheckmarkIcon = true - setTimeout(() => { this.showCheckmarkIcon = false }, 2000) - } else { - showError(errorMessage) - this.logger.error(errorMessage, error) - this.showErrorIcon = true - setTimeout(() => { this.showErrorIcon = false }, 2000) - } - }, - - onScopeChange(scope) { - this.$emit('update:scope', scope) - }, - }, -} -</script> - -<style lang="scss" scoped> -.headline { - display: grid; - align-items: center; - - input { - grid-area: 1 / 1; - width: 100%; - height: 34px; - margin: 3px 3px 3px 0; - padding: 7px 6px; - color: var(--color-main-text); - border: 1px solid var(--color-border-dark); - border-radius: var(--border-radius); - background-color: var(--color-main-background); - font-family: var(--font-face); - cursor: text; - } - - .headline__actions-container { - grid-area: 1 / 1; - justify-self: flex-end; - height: 30px; - - display: flex; - gap: 0 2px; - margin-right: 5px; - - .icon-checkmark, - .icon-error { - height: 30px !important; - min-height: 30px !important; - width: 30px !important; - min-width: 30px !important; - top: 0; - right: 0; - float: none; - } - } -} - -.fade-enter, -.fade-leave-to { - opacity: 0; -} - -.fade-enter-active { - transition: opacity 200ms ease-out; -} - -.fade-leave-active { - transition: opacity 300ms ease-out; -} -</style> diff --git a/apps/settings/src/components/PersonalInfo/HeadlineSection/HeadlineSection.vue b/apps/settings/src/components/PersonalInfo/HeadlineSection/HeadlineSection.vue deleted file mode 100644 index 4f3714aa0ee..00000000000 --- a/apps/settings/src/components/PersonalInfo/HeadlineSection/HeadlineSection.vue +++ /dev/null @@ -1,69 +0,0 @@ -<!-- - - @copyright 2021, Christopher Ng <chrng8@gmail.com> - - - - @author Christopher Ng <chrng8@gmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - ---> - -<template> - <section> - <HeaderBar :account-property="accountProperty" - label-for="headline" - :scope.sync="primaryHeadline.scope" /> - - <Headline :headline.sync="primaryHeadline.value" - :scope.sync="primaryHeadline.scope" /> - </section> -</template> - -<script> -import { loadState } from '@nextcloud/initial-state' - -import Headline from './Headline' -import HeaderBar from '../shared/HeaderBar' - -import { ACCOUNT_PROPERTY_READABLE_ENUM } from '../../../constants/AccountPropertyConstants' - -const { headlineMap: { primaryHeadline } } = loadState('settings', 'personalInfoParameters', {}) - -export default { - name: 'HeadlineSection', - - components: { - Headline, - HeaderBar, - }, - - data() { - return { - accountProperty: ACCOUNT_PROPERTY_READABLE_ENUM.HEADLINE, - primaryHeadline, - } - }, -} -</script> - -<style lang="scss" scoped> -section { - padding: 10px 10px; - - &::v-deep button:disabled { - cursor: default; - } -} -</style> diff --git a/apps/settings/src/components/PersonalInfo/LanguageSection/Language.vue b/apps/settings/src/components/PersonalInfo/LanguageSection/Language.vue index 2f11f493207..8f42b2771c0 100644 --- a/apps/settings/src/components/PersonalInfo/LanguageSection/Language.vue +++ b/apps/settings/src/components/PersonalInfo/LanguageSection/Language.vue @@ -1,46 +1,19 @@ <!-- - - @copyright 2021, Christopher Ng <chrng8@gmail.com> - - - - @author Christopher Ng <chrng8@gmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - + - SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later --> <template> <div class="language"> - <select id="language" - :placeholder="t('settings', 'Language')" - @change="onLanguageChange"> - <option v-for="commonLanguage in commonLanguages" - :key="commonLanguage.code" - :selected="language.code === commonLanguage.code" - :value="commonLanguage.code"> - {{ commonLanguage.name }} - </option> - <option disabled> - ────────── - </option> - <option v-for="otherLanguage in otherLanguages" - :key="otherLanguage.code" - :selected="language.code === otherLanguage.code" - :value="otherLanguage.code"> - {{ otherLanguage.name }} - </option> - </select> + <NcSelect :aria-label-listbox="t('settings', 'Languages')" + class="language__select" + :clearable="false" + :input-id="inputId" + label="name" + label-outside + :options="allLanguages" + :value="language" + @option:selected="onLanguageChange" /> <a href="https://www.transifex.com/nextcloud/nextcloud/" target="_blank" @@ -51,16 +24,25 @@ </template> <script> -import { showError } from '@nextcloud/dialogs' +import { ACCOUNT_SETTING_PROPERTY_ENUM } from '../../../constants/AccountPropertyConstants.js' +import { savePrimaryAccountProperty } from '../../../service/PersonalInfo/PersonalInfoService.js' +import { validateLanguage } from '../../../utils/validate.js' +import { handleError } from '../../../utils/handlers.ts' -import { ACCOUNT_SETTING_PROPERTY_ENUM } from '../../../constants/AccountPropertyConstants' -import { savePrimaryAccountProperty } from '../../../service/PersonalInfo/PersonalInfoService' -import { validateLanguage } from '../../../utils/validate' +import NcSelect from '@nextcloud/vue/components/NcSelect' export default { name: 'Language', + components: { + NcSelect, + }, + props: { + inputId: { + type: String, + default: null, + }, commonLanguages: { type: Array, required: true, @@ -82,17 +64,18 @@ export default { }, computed: { + /** + * All available languages, sorted like: current, common, other + */ allLanguages() { - return Object.freeze( - [...this.commonLanguages, ...this.otherLanguages] - .reduce((acc, { code, name }) => ({ ...acc, [code]: name }), {}) - ) + const common = this.commonLanguages.filter(l => l.code !== this.language.code) + const other = this.otherLanguages.filter(l => l.code !== this.language.code) + return [this.language, ...common, ...other] }, }, methods: { - async onLanguageChange(e) { - const language = this.constructLanguage(e.target.value) + async onLanguageChange(language) { this.$emit('update:language', language) if (validateLanguage(language)) { @@ -107,7 +90,7 @@ export default { language, status: responseData.ocs?.meta?.status, }) - this.reloadPage() + window.location.reload() } catch (e) { this.handleResponse({ errorMessage: t('settings', 'Unable to update language'), @@ -116,26 +99,14 @@ export default { } }, - constructLanguage(languageCode) { - return { - code: languageCode, - name: this.allLanguages[languageCode], - } - }, - handleResponse({ language, status, errorMessage, error }) { if (status === 'ok') { // Ensure that local state reflects server state this.initialLanguage = language } else { - showError(errorMessage) - this.logger.error(errorMessage, error) + handleError(error, errorMessage) } }, - - reloadPage() { - location.reload() - }, }, } </script> @@ -144,22 +115,11 @@ export default { .language { display: grid; - select { - width: 100%; - height: 34px; - margin: 3px 3px 3px 0; - padding: 6px 16px; - color: var(--color-main-text); - border: 1px solid var(--color-border-dark); - border-radius: var(--border-radius); - background: var(--icon-triangle-s-000) no-repeat right 4px center; - font-family: var(--font-face); - appearance: none; - cursor: pointer; + #{&}__select { + margin-top: 6px; // align with other inputs } a { - color: var(--color-main-text); text-decoration: none; width: max-content; } diff --git a/apps/settings/src/components/PersonalInfo/LanguageSection/LanguageSection.vue b/apps/settings/src/components/PersonalInfo/LanguageSection/LanguageSection.vue index 90882b23869..4e92436fd63 100644 --- a/apps/settings/src/components/PersonalInfo/LanguageSection/LanguageSection.vue +++ b/apps/settings/src/components/PersonalInfo/LanguageSection/LanguageSection.vue @@ -1,35 +1,18 @@ <!-- - - @copyright 2021, Christopher Ng <chrng8@gmail.com> - - - - @author Christopher Ng <chrng8@gmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - + - SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later --> <template> <section> - <HeaderBar :account-property="accountProperty" - label-for="language" /> + <HeaderBar :input-id="inputId" + :readable="propertyReadable" /> - <template v-if="isEditable"> - <Language :common-languages="commonLanguages" - :other-languages="otherLanguages" - :language.sync="language" /> - </template> + <Language v-if="isEditable" + :input-id="inputId" + :common-languages="commonLanguages" + :other-languages="otherLanguages" + :language.sync="language" /> <span v-else> {{ t('settings', 'No language set') }} @@ -40,10 +23,10 @@ <script> import { loadState } from '@nextcloud/initial-state' -import Language from './Language' -import HeaderBar from '../shared/HeaderBar' +import Language from './Language.vue' +import HeaderBar from '../shared/HeaderBar.vue' -import { ACCOUNT_SETTING_PROPERTY_READABLE_ENUM } from '../../../constants/AccountPropertyConstants' +import { ACCOUNT_SETTING_PROPERTY_ENUM, ACCOUNT_SETTING_PROPERTY_READABLE_ENUM } from '../../../constants/AccountPropertyConstants.js' const { languageMap: { activeLanguage, commonLanguages, otherLanguages } } = loadState('settings', 'personalInfoParameters', {}) @@ -55,16 +38,26 @@ export default { HeaderBar, }, - data() { + setup() { + // Non reactive instance properties return { - accountProperty: ACCOUNT_SETTING_PROPERTY_READABLE_ENUM.LANGUAGE, commonLanguages, otherLanguages, + propertyReadable: ACCOUNT_SETTING_PROPERTY_READABLE_ENUM.LANGUAGE, + } + }, + + data() { + return { language: activeLanguage, } }, computed: { + inputId() { + return `account-setting-${ACCOUNT_SETTING_PROPERTY_ENUM.LANGUAGE}` + }, + isEditable() { return Boolean(this.language) }, @@ -75,9 +68,5 @@ export default { <style lang="scss" scoped> section { padding: 10px 10px; - - &::v-deep button:disabled { - cursor: default; - } } </style> diff --git a/apps/settings/src/components/PersonalInfo/LocaleSection/Locale.vue b/apps/settings/src/components/PersonalInfo/LocaleSection/Locale.vue new file mode 100644 index 00000000000..73300756472 --- /dev/null +++ b/apps/settings/src/components/PersonalInfo/LocaleSection/Locale.vue @@ -0,0 +1,157 @@ +<!-- + - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <div class="locale"> + <NcSelect :aria-label-listbox="t('settings', 'Locales')" + class="locale__select" + :clearable="false" + :input-id="inputId" + label="name" + label-outside + :options="allLocales" + :value="locale" + @option:selected="updateLocale" /> + + <div class="example"> + <MapClock :size="20" /> + <div class="example__text"> + <p> + <span>{{ example.date }}</span> + <span>{{ example.time }}</span> + </p> + <p> + {{ t('settings', 'Week starts on {firstDayOfWeek}', { firstDayOfWeek: example.firstDayOfWeek }) }} + </p> + </div> + </div> + </div> +</template> + +<script> +import moment from '@nextcloud/moment' +import NcSelect from '@nextcloud/vue/components/NcSelect' +import MapClock from 'vue-material-design-icons/MapClock.vue' + +import { ACCOUNT_SETTING_PROPERTY_ENUM } from '../../../constants/AccountPropertyConstants.js' +import { savePrimaryAccountProperty } from '../../../service/PersonalInfo/PersonalInfoService.js' +import { handleError } from '../../../utils/handlers.ts' + +export default { + name: 'Locale', + + components: { + MapClock, + NcSelect, + }, + + props: { + inputId: { + type: String, + default: null, + }, + locale: { + type: Object, + required: true, + }, + localesForLanguage: { + type: Array, + required: true, + }, + otherLocales: { + type: Array, + required: true, + }, + }, + + data() { + return { + initialLocale: this.locale, + intervalId: 0, + example: { + date: moment().format('L'), + time: moment().format('LTS'), + firstDayOfWeek: window.dayNames[window.firstDay], + }, + } + }, + + computed: { + /** + * All available locale, sorted like: current, common, other + */ + allLocales() { + const common = this.localesForLanguage.filter(l => l.code !== this.locale.code) + const other = this.otherLocales.filter(l => l.code !== this.locale.code) + return [this.locale, ...common, ...other] + }, + }, + + mounted() { + this.intervalId = window.setInterval(this.refreshExample, 1000) + }, + + beforeDestroy() { + window.clearInterval(this.intervalId) + }, + + methods: { + async updateLocale(locale) { + try { + const responseData = await savePrimaryAccountProperty(ACCOUNT_SETTING_PROPERTY_ENUM.LOCALE, locale.code) + this.handleResponse({ + locale, + status: responseData.ocs?.meta?.status, + }) + window.location.reload() + } catch (e) { + this.handleResponse({ + errorMessage: t('settings', 'Unable to update locale'), + error: e, + }) + } + }, + + handleResponse({ locale, status, errorMessage, error }) { + if (status === 'ok') { + this.initialLocale = locale + } else { + this.$emit('update:locale', this.initialLocale) + handleError(error, errorMessage) + } + }, + + refreshExample() { + this.example = { + date: moment().format('L'), + time: moment().format('LTS'), + firstDayOfWeek: window.dayNames[window.firstDay], + } + }, + }, +} +</script> + +<style lang="scss" scoped> +.locale { + display: grid; + + #{&}__select { + margin-top: 6px; // align with other inputs + } +} + +.example { + margin: 10px 0; + display: flex; + gap: 0 10px; + color: var(--color-text-maxcontrast); + + &:deep(.material-design-icon) { + align-self: flex-start; + margin-top: 2px; + } +} +</style> diff --git a/apps/settings/src/components/PersonalInfo/LocaleSection/LocaleSection.vue b/apps/settings/src/components/PersonalInfo/LocaleSection/LocaleSection.vue new file mode 100644 index 00000000000..d4488e77efd --- /dev/null +++ b/apps/settings/src/components/PersonalInfo/LocaleSection/LocaleSection.vue @@ -0,0 +1,66 @@ +<!-- + - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <section> + <HeaderBar :input-id="inputId" + :readable="propertyReadable" /> + + <Locale v-if="isEditable" + :input-id="inputId" + :locales-for-language="localesForLanguage" + :other-locales="otherLocales" + :locale.sync="locale" /> + + <span v-else> + {{ t('settings', 'No locale set') }} + </span> + </section> +</template> + +<script> +import { loadState } from '@nextcloud/initial-state' + +import Locale from './Locale.vue' +import HeaderBar from '../shared/HeaderBar.vue' + +import { ACCOUNT_SETTING_PROPERTY_ENUM, ACCOUNT_SETTING_PROPERTY_READABLE_ENUM } from '../../../constants/AccountPropertyConstants.js' + +const { localeMap: { activeLocale, localesForLanguage, otherLocales } } = loadState('settings', 'personalInfoParameters', {}) + +export default { + name: 'LocaleSection', + + components: { + Locale, + HeaderBar, + }, + + data() { + return { + propertyReadable: ACCOUNT_SETTING_PROPERTY_READABLE_ENUM.LOCALE, + localesForLanguage, + otherLocales, + locale: activeLocale, + } + }, + + computed: { + inputId() { + return `account-setting-${ACCOUNT_SETTING_PROPERTY_ENUM.LOCALE}` + }, + + isEditable() { + return Boolean(this.locale) + }, + }, +} +</script> + +<style lang="scss" scoped> +section { + padding: 10px 10px; +} +</style> diff --git a/apps/settings/src/components/PersonalInfo/LocationSection.vue b/apps/settings/src/components/PersonalInfo/LocationSection.vue new file mode 100644 index 00000000000..a32f86b3442 --- /dev/null +++ b/apps/settings/src/components/PersonalInfo/LocationSection.vue @@ -0,0 +1,34 @@ +<!-- + - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <AccountPropertySection v-bind.sync="location" + autocomplete="address-level1" + :placeholder="t('settings', 'Your city')" /> +</template> + +<script> +import { loadState } from '@nextcloud/initial-state' + +import AccountPropertySection from './shared/AccountPropertySection.vue' + +import { NAME_READABLE_ENUM } from '../../constants/AccountPropertyConstants.js' + +const { location } = loadState('settings', 'personalInfoParameters', {}) + +export default { + name: 'LocationSection', + + components: { + AccountPropertySection, + }, + + data() { + return { + location: { ...location, readable: NAME_READABLE_ENUM[location.name] }, + } + }, +} +</script> diff --git a/apps/settings/src/components/PersonalInfo/OrganisationSection.vue b/apps/settings/src/components/PersonalInfo/OrganisationSection.vue new file mode 100644 index 00000000000..b951b938919 --- /dev/null +++ b/apps/settings/src/components/PersonalInfo/OrganisationSection.vue @@ -0,0 +1,34 @@ +<!-- + - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <AccountPropertySection v-bind.sync="organisation" + autocomplete="organization" + :placeholder="t('settings', 'Your organisation')" /> +</template> + +<script> +import { loadState } from '@nextcloud/initial-state' + +import AccountPropertySection from './shared/AccountPropertySection.vue' + +import { NAME_READABLE_ENUM } from '../../constants/AccountPropertyConstants.js' + +const { organisation } = loadState('settings', 'personalInfoParameters', {}) + +export default { + name: 'OrganisationSection', + + components: { + AccountPropertySection, + }, + + data() { + return { + organisation: { ...organisation, readable: NAME_READABLE_ENUM[organisation.name] }, + } + }, +} +</script> diff --git a/apps/settings/src/components/PersonalInfo/OrganisationSection/Organisation.vue b/apps/settings/src/components/PersonalInfo/OrganisationSection/Organisation.vue deleted file mode 100644 index 106c5b6f6ff..00000000000 --- a/apps/settings/src/components/PersonalInfo/OrganisationSection/Organisation.vue +++ /dev/null @@ -1,174 +0,0 @@ -<!-- - - @copyright 2021, Christopher Ng <chrng8@gmail.com> - - - - @author Christopher Ng <chrng8@gmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - ---> - -<template> - <div class="organisation"> - <input id="organisation" - type="text" - :placeholder="t('settings', 'Your organisation')" - :value="organisation" - autocapitalize="none" - autocomplete="on" - autocorrect="off" - @input="onOrganisationChange"> - - <div class="organisation__actions-container"> - <transition name="fade"> - <span v-if="showCheckmarkIcon" class="icon-checkmark" /> - <span v-else-if="showErrorIcon" class="icon-error" /> - </transition> - </div> - </div> -</template> - -<script> -import { showError } from '@nextcloud/dialogs' -import { emit } from '@nextcloud/event-bus' -import debounce from 'debounce' - -import { ACCOUNT_PROPERTY_ENUM } from '../../../constants/AccountPropertyConstants' -import { savePrimaryAccountProperty } from '../../../service/PersonalInfo/PersonalInfoService' - -export default { - name: 'Organisation', - - props: { - organisation: { - type: String, - required: true, - }, - scope: { - type: String, - required: true, - }, - }, - - data() { - return { - initialOrganisation: this.organisation, - localScope: this.scope, - showCheckmarkIcon: false, - showErrorIcon: false, - } - }, - - methods: { - onOrganisationChange(e) { - this.$emit('update:organisation', e.target.value) - this.debounceOrganisationChange(e.target.value.trim()) - }, - - debounceOrganisationChange: debounce(async function(organisation) { - await this.updatePrimaryOrganisation(organisation) - }, 500), - - async updatePrimaryOrganisation(organisation) { - try { - const responseData = await savePrimaryAccountProperty(ACCOUNT_PROPERTY_ENUM.ORGANISATION, organisation) - this.handleResponse({ - organisation, - status: responseData.ocs?.meta?.status, - }) - } catch (e) { - this.handleResponse({ - errorMessage: t('settings', 'Unable to update organisation'), - error: e, - }) - } - }, - - handleResponse({ organisation, status, errorMessage, error }) { - if (status === 'ok') { - // Ensure that local state reflects server state - this.initialOrganisation = organisation - emit('settings:organisation:updated', organisation) - this.showCheckmarkIcon = true - setTimeout(() => { this.showCheckmarkIcon = false }, 2000) - } else { - showError(errorMessage) - this.logger.error(errorMessage, error) - this.showErrorIcon = true - setTimeout(() => { this.showErrorIcon = false }, 2000) - } - }, - - onScopeChange(scope) { - this.$emit('update:scope', scope) - }, - }, -} -</script> - -<style lang="scss" scoped> -.organisation { - display: grid; - align-items: center; - - input { - grid-area: 1 / 1; - width: 100%; - height: 34px; - margin: 3px 3px 3px 0; - padding: 7px 6px; - color: var(--color-main-text); - border: 1px solid var(--color-border-dark); - border-radius: var(--border-radius); - background-color: var(--color-main-background); - font-family: var(--font-face); - cursor: text; - } - - .organisation__actions-container { - grid-area: 1 / 1; - justify-self: flex-end; - height: 30px; - - display: flex; - gap: 0 2px; - margin-right: 5px; - - .icon-checkmark, - .icon-error { - height: 30px !important; - min-height: 30px !important; - width: 30px !important; - min-width: 30px !important; - top: 0; - right: 0; - float: none; - } - } -} - -.fade-enter, -.fade-leave-to { - opacity: 0; -} - -.fade-enter-active { - transition: opacity 200ms ease-out; -} - -.fade-leave-active { - transition: opacity 300ms ease-out; -} -</style> diff --git a/apps/settings/src/components/PersonalInfo/OrganisationSection/OrganisationSection.vue b/apps/settings/src/components/PersonalInfo/OrganisationSection/OrganisationSection.vue deleted file mode 100644 index 2a0b93d552f..00000000000 --- a/apps/settings/src/components/PersonalInfo/OrganisationSection/OrganisationSection.vue +++ /dev/null @@ -1,69 +0,0 @@ -<!-- - - @copyright 2021, Christopher Ng <chrng8@gmail.com> - - - - @author Christopher Ng <chrng8@gmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - ---> - -<template> - <section> - <HeaderBar :account-property="accountProperty" - label-for="organisation" - :scope.sync="primaryOrganisation.scope" /> - - <Organisation :organisation.sync="primaryOrganisation.value" - :scope.sync="primaryOrganisation.scope" /> - </section> -</template> - -<script> -import { loadState } from '@nextcloud/initial-state' - -import Organisation from './Organisation' -import HeaderBar from '../shared/HeaderBar' - -import { ACCOUNT_PROPERTY_READABLE_ENUM } from '../../../constants/AccountPropertyConstants' - -const { organisationMap: { primaryOrganisation } } = loadState('settings', 'personalInfoParameters', {}) - -export default { - name: 'OrganisationSection', - - components: { - Organisation, - HeaderBar, - }, - - data() { - return { - accountProperty: ACCOUNT_PROPERTY_READABLE_ENUM.ORGANISATION, - primaryOrganisation, - } - }, -} -</script> - -<style lang="scss" scoped> -section { - padding: 10px 10px; - - &::v-deep button:disabled { - cursor: default; - } -} -</style> diff --git a/apps/settings/src/components/PersonalInfo/PhoneSection.vue b/apps/settings/src/components/PersonalInfo/PhoneSection.vue new file mode 100644 index 00000000000..8ddeada960e --- /dev/null +++ b/apps/settings/src/components/PersonalInfo/PhoneSection.vue @@ -0,0 +1,53 @@ +<!-- + - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <AccountPropertySection v-bind.sync="phone" + :placeholder="t('settings', 'Your phone number')" + autocomplete="tel" + type="tel" + :on-validate="onValidate" /> +</template> + +<script> +import { isValidPhoneNumber } from 'libphonenumber-js' +import { loadState } from '@nextcloud/initial-state' + +import AccountPropertySection from './shared/AccountPropertySection.vue' + +import { NAME_READABLE_ENUM } from '../../constants/AccountPropertyConstants.js' + +const { + defaultPhoneRegion, + phone, +} = loadState('settings', 'personalInfoParameters', {}) + +export default { + name: 'PhoneSection', + + components: { + AccountPropertySection, + }, + + data() { + return { + phone: { ...phone, readable: NAME_READABLE_ENUM[phone.name] }, + } + }, + + methods: { + onValidate(value) { + if (value === '') { + return true + } + + if (defaultPhoneRegion) { + return isValidPhoneNumber(value, defaultPhoneRegion) + } + return isValidPhoneNumber(value) + }, + }, +} +</script> diff --git a/apps/settings/src/components/PersonalInfo/ProfileSection/EditProfileAnchorLink.vue b/apps/settings/src/components/PersonalInfo/ProfileSection/EditProfileAnchorLink.vue index 1ee3bc0e149..3deb5340751 100644 --- a/apps/settings/src/components/PersonalInfo/ProfileSection/EditProfileAnchorLink.vue +++ b/apps/settings/src/components/PersonalInfo/ProfileSection/EditProfileAnchorLink.vue @@ -1,23 +1,6 @@ <!-- - - @copyright 2021 Christopher Ng <chrng8@gmail.com> - - - - @author Christopher Ng <chrng8@gmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - + - SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later --> <template> @@ -25,15 +8,13 @@ href="#profile-visibility" v-on="$listeners"> <ChevronDownIcon class="anchor-icon" - decorative - title="" :size="22" /> {{ t('settings', 'Edit your Profile visibility') }} </a> </template> <script> -import ChevronDownIcon from 'vue-material-design-icons/ChevronDown' +import ChevronDownIcon from 'vue-material-design-icons/ChevronDown.vue' export default { name: 'EditProfileAnchorLink', @@ -71,26 +52,28 @@ html { a { display: block; height: 44px; - width: 290px; + width: min(100%, 290px); + overflow: hidden; + text-overflow: ellipsis; line-height: 44px; padding: 0 16px; margin: 14px auto; border-radius: var(--border-radius-pill); - opacity: 0.4; + color: var(--color-text-maxcontrast); background-color: transparent; .anchor-icon { display: inline-block; vertical-align: middle; margin-top: 6px; - margin-right: 8px; + margin-inline-end: 8px; } &:hover, &:focus, &:active { - opacity: 0.8; - background-color: rgba(127, 127, 127, .25); + color: var(--color-main-text); + background-color: var(--color-background-dark); } &.disabled { diff --git a/apps/settings/src/components/PersonalInfo/ProfileSection/ProfileCheckbox.vue b/apps/settings/src/components/PersonalInfo/ProfileSection/ProfileCheckbox.vue index d7e78915c5d..6eb7cf8c34c 100644 --- a/apps/settings/src/components/PersonalInfo/ProfileSection/ProfileCheckbox.vue +++ b/apps/settings/src/components/PersonalInfo/ProfileSection/ProfileCheckbox.vue @@ -1,49 +1,34 @@ <!-- - - @copyright 2021, Christopher Ng <chrng8@gmail.com> - - - - @author Christopher Ng <chrng8@gmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - + - SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later --> <template> <div class="checkbox-container"> - <input id="enable-profile" - class="checkbox" - type="checkbox" - :checked="profileEnabled" - @change="onEnableProfileChange"> - <label for="enable-profile"> - {{ t('settings', 'Enable Profile') }} - </label> + <NcCheckboxRadioSwitch type="switch" + :checked.sync="isProfileEnabled" + :loading="loading" + @update:checked="saveEnableProfile"> + {{ t('settings', 'Enable profile') }} + </NcCheckboxRadioSwitch> </div> </template> <script> -import { showError } from '@nextcloud/dialogs' import { emit } from '@nextcloud/event-bus' -import { savePrimaryAccountProperty } from '../../../service/PersonalInfo/PersonalInfoService' -import { validateBoolean } from '../../../utils/validate' -import { ACCOUNT_PROPERTY_ENUM } from '../../../constants/AccountPropertyConstants' +import { savePrimaryAccountProperty } from '../../../service/PersonalInfo/PersonalInfoService.js' +import { ACCOUNT_PROPERTY_ENUM } from '../../../constants/AccountPropertyConstants.js' +import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch' +import { handleError } from '../../../utils/handlers.ts' export default { name: 'ProfileCheckbox', + components: { + NcCheckboxRadioSwitch, + }, + props: { profileEnabled: { type: Boolean, @@ -53,25 +38,18 @@ export default { data() { return { - initialProfileEnabled: this.profileEnabled, + isProfileEnabled: this.profileEnabled, + loading: false, } }, methods: { - async onEnableProfileChange(e) { - const isEnabled = e.target.checked - this.$emit('update:profile-enabled', isEnabled) - - if (validateBoolean(isEnabled)) { - await this.updateEnableProfile(isEnabled) - } - }, - - async updateEnableProfile(isEnabled) { + async saveEnableProfile() { + this.loading = true try { - const responseData = await savePrimaryAccountProperty(ACCOUNT_PROPERTY_ENUM.PROFILE_ENABLED, isEnabled) + const responseData = await savePrimaryAccountProperty(ACCOUNT_PROPERTY_ENUM.PROFILE_ENABLED, this.isProfileEnabled) this.handleResponse({ - isEnabled, + isProfileEnabled: this.isProfileEnabled, status: responseData.ocs?.meta?.status, }) } catch (e) { @@ -82,19 +60,14 @@ export default { } }, - handleResponse({ isEnabled, status, errorMessage, error }) { + handleResponse({ isProfileEnabled, status, errorMessage, error }) { if (status === 'ok') { - // Ensure that local state reflects server state - this.initialProfileEnabled = isEnabled - emit('settings:profile-enabled:updated', isEnabled) + emit('settings:profile-enabled:updated', isProfileEnabled) } else { - showError(errorMessage) - this.logger.error(errorMessage, error) + handleError(error, errorMessage) } + this.loading = false }, }, } </script> - -<style lang="scss" scoped> -</style> diff --git a/apps/settings/src/components/PersonalInfo/ProfileSection/ProfilePreviewCard.vue b/apps/settings/src/components/PersonalInfo/ProfileSection/ProfilePreviewCard.vue index ef12d511fb9..47894f64f34 100644 --- a/apps/settings/src/components/PersonalInfo/ProfileSection/ProfilePreviewCard.vue +++ b/apps/settings/src/components/PersonalInfo/ProfileSection/ProfilePreviewCard.vue @@ -1,30 +1,13 @@ <!-- - - @copyright 2021, Christopher Ng <chrng8@gmail.com> - - - - @author Christopher Ng <chrng8@gmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - + - SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later --> <template> <a class="preview-card" :class="{ disabled }" :href="profilePageLink"> - <Avatar class="preview-card__avatar" + <NcAvatar class="preview-card__avatar" :user="userId" :size="48" :show-user-status="true" @@ -44,13 +27,13 @@ import { getCurrentUser } from '@nextcloud/auth' import { generateUrl } from '@nextcloud/router' -import Avatar from '@nextcloud/vue/dist/Components/Avatar' +import NcAvatar from '@nextcloud/vue/components/NcAvatar' export default { name: 'ProfilePreviewCard', components: { - Avatar, + NcAvatar, }, props: { @@ -95,7 +78,7 @@ export default { display: flex; flex-direction: column; position: relative; - width: 290px; + width: min(100%, 290px); height: 116px; margin: 14px auto; border-radius: var(--border-radius-large); @@ -121,7 +104,7 @@ export default { box-shadow: 0 0 3px var(--color-box-shadow); & *, - &::v-deep * { + &:deep(*) { cursor: default; } } @@ -130,7 +113,7 @@ export default { // Override Avatar component position to fix positioning on rerender position: absolute !important; top: 40px; - left: 18px; + inset-inline-start: 18px; z-index: 1; &:not(.avatardiv--unknown) { @@ -145,10 +128,10 @@ export default { span { position: absolute; - left: 78px; + inset-inline-start: 78px; overflow: hidden; text-overflow: ellipsis; - word-break: break-all; + overflow-wrap: anywhere; @supports (-webkit-line-clamp: 2) { display: -webkit-box; @@ -161,15 +144,15 @@ export default { &__header { height: 70px; border-radius: var(--border-radius-large) var(--border-radius-large) 0 0; - background-color: var(--color-primary); - background-image: linear-gradient(40deg, var(--color-primary) 0%, var(--color-primary-element-light) 100%); + background-color: var(--color-primary-element); span { bottom: 0; - color: var(--color-primary-text); + color: var(--color-primary-element-text); font-size: 18px; font-weight: bold; - margin: 0 4px 8px 0; + margin-block: 0 8px; + margin-inline: 0 4px; } } @@ -181,7 +164,8 @@ export default { color: var(--color-text-maxcontrast); font-size: 14px; font-weight: normal; - margin: 4px 4px 0 0; + margin-block: 4px 0; + margin-inline: 0 4px; line-height: 1.3; } } diff --git a/apps/settings/src/components/PersonalInfo/ProfileSection/ProfileSection.vue b/apps/settings/src/components/PersonalInfo/ProfileSection/ProfileSection.vue index 46048e96c0e..22c03f72697 100644 --- a/apps/settings/src/components/PersonalInfo/ProfileSection/ProfileSection.vue +++ b/apps/settings/src/components/PersonalInfo/ProfileSection/ProfileSection.vue @@ -1,28 +1,11 @@ <!-- - - @copyright 2021, Christopher Ng <chrng8@gmail.com> - - - - @author Christopher Ng <chrng8@gmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - + - SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later --> <template> <section> - <HeaderBar :account-property="accountProperty" /> + <HeaderBar :is-heading="true" :readable="propertyReadable" /> <ProfileCheckbox :profile-enabled.sync="profileEnabled" /> @@ -39,16 +22,16 @@ import { loadState } from '@nextcloud/initial-state' import { subscribe, unsubscribe } from '@nextcloud/event-bus' -import EditProfileAnchorLink from './EditProfileAnchorLink' -import HeaderBar from '../shared/HeaderBar' -import ProfileCheckbox from './ProfileCheckbox' -import ProfilePreviewCard from './ProfilePreviewCard' +import EditProfileAnchorLink from './EditProfileAnchorLink.vue' +import HeaderBar from '../shared/HeaderBar.vue' +import ProfileCheckbox from './ProfileCheckbox.vue' +import ProfilePreviewCard from './ProfilePreviewCard.vue' -import { ACCOUNT_PROPERTY_READABLE_ENUM } from '../../../constants/AccountPropertyConstants' +import { ACCOUNT_PROPERTY_READABLE_ENUM } from '../../../constants/AccountPropertyConstants.js' const { - organisationMap: { primaryOrganisation: { value: organisation } }, - displayNameMap: { primaryDisplayName: { value: displayName } }, + organisation: { value: organisation }, + displayName: { value: displayName }, profileEnabled, userId, } = loadState('settings', 'personalInfoParameters', {}) @@ -65,7 +48,7 @@ export default { data() { return { - accountProperty: ACCOUNT_PROPERTY_READABLE_ENUM.PROFILE_ENABLED, + propertyReadable: ACCOUNT_PROPERTY_READABLE_ENUM.PROFILE_ENABLED, organisation, displayName, profileEnabled, @@ -99,7 +82,7 @@ export default { section { padding: 10px 10px; - &::v-deep button:disabled { + &:deep(button:disabled) { cursor: default; } } diff --git a/apps/settings/src/components/PersonalInfo/ProfileVisibilitySection/ProfileVisibilitySection.vue b/apps/settings/src/components/PersonalInfo/ProfileVisibilitySection/ProfileVisibilitySection.vue index 16a46fee969..8acec883842 100644 --- a/apps/settings/src/components/PersonalInfo/ProfileVisibilitySection/ProfileVisibilitySection.vue +++ b/apps/settings/src/components/PersonalInfo/ProfileVisibilitySection/ProfileVisibilitySection.vue @@ -1,30 +1,13 @@ <!-- - - @copyright 2021 Christopher Ng <chrng8@gmail.com> - - - - @author Christopher Ng <chrng8@gmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - + - SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later --> <template> <!-- TODO remove this inline margin placeholder once the settings layout is updated --> <section id="profile-visibility" :style="{ marginLeft }"> - <HeaderBar :account-property="heading" /> + <HeaderBar :is-heading="true" :readable="heading" /> <em :class="{ disabled }"> {{ t('settings', 'The more restrictive setting of either visibility or scope is respected on your Profile. For example, if visibility is set to "Show to everyone" and scope is set to "Private", "Private" is respected.') }} @@ -47,9 +30,9 @@ import { loadState } from '@nextcloud/initial-state' import { subscribe, unsubscribe } from '@nextcloud/event-bus' -import HeaderBar from '../shared/HeaderBar' -import VisibilityDropdown from './VisibilityDropdown' -import { PROFILE_READABLE_ENUM } from '../../../constants/AccountPropertyConstants' +import HeaderBar from '../shared/HeaderBar.vue' +import VisibilityDropdown from './VisibilityDropdown.vue' +import { PROFILE_READABLE_ENUM } from '../../../constants/AccountPropertyConstants.js' const { profileConfig } = loadState('settings', 'profileParameters', {}) const { profileEnabled } = loadState('settings', 'personalInfoParameters', false) @@ -81,7 +64,7 @@ export default { .sort(compareParams), // TODO remove this when not used once the settings layout is updated marginLeft: window.matchMedia('(min-width: 1600px)').matches - ? window.getComputedStyle(document.getElementById('personal-settings-avatar-container')).getPropertyValue('width').trim() + ? window.getComputedStyle(document.getElementById('vue-avatar-section')).getPropertyValue('width').trim() : '0px', } }, @@ -101,7 +84,7 @@ export default { // TODO remove this when not used once the settings layout is updated window.onresize = () => { this.marginLeft = window.matchMedia('(min-width: 1600px)').matches - ? window.getComputedStyle(document.getElementById('personal-settings-avatar-container')).getPropertyValue('width').trim() + ? window.getComputedStyle(document.getElementById('vue-avatar-section')).getPropertyValue('width').trim() : '0px' } }, @@ -121,7 +104,8 @@ export default { <style lang="scss" scoped> section { padding: 30px; - max-width: 100vw; + max-width: 900px; + width: 100%; em { display: block; @@ -134,28 +118,11 @@ section { pointer-events: none; & *, - &::v-deep * { + &:deep(*) { cursor: default; pointer-events: none; } } } - - .visibility-dropdowns { - display: grid; - gap: 10px 40px; - } - - @media (min-width: 1200px) { - width: 940px; - - .visibility-dropdowns { - grid-auto-flow: column; - } - } - - @media (max-width: 1200px) { - width: 470px; - } } </style> diff --git a/apps/settings/src/components/PersonalInfo/ProfileVisibilitySection/VisibilityDropdown.vue b/apps/settings/src/components/PersonalInfo/ProfileVisibilitySection/VisibilityDropdown.vue index e057d5f0a08..aaa13e63e92 100644 --- a/apps/settings/src/components/PersonalInfo/ProfileVisibilitySection/VisibilityDropdown.vue +++ b/apps/settings/src/components/PersonalInfo/ProfileVisibilitySection/VisibilityDropdown.vue @@ -1,51 +1,33 @@ <!-- - - @copyright 2021 Christopher Ng <chrng8@gmail.com> - - - - @author Christopher Ng <chrng8@gmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - + - SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later --> <template> <div class="visibility-container" :class="{ disabled }"> <label :for="inputId"> - {{ t('settings', '{displayId}', { displayId }) }} + {{ displayId }} </label> - <Multiselect :id="inputId" - class="visibility-container__multiselect" + <NcSelect :input-id="inputId" + class="visibility-container__select" + :clearable="false" :options="visibilityOptions" - track-by="name" - label="label" :value="visibilityObject" - @change="onVisibilityChange" /> + label-outside + @option:selected="onVisibilityChange" /> </div> </template> <script> -import { showError } from '@nextcloud/dialogs' import { loadState } from '@nextcloud/initial-state' import { subscribe, unsubscribe } from '@nextcloud/event-bus' -import Multiselect from '@nextcloud/vue/dist/Components/Multiselect' +import NcSelect from '@nextcloud/vue/components/NcSelect' -import { saveProfileParameterVisibility } from '../../../service/ProfileService' -import { validateStringInput } from '../../../utils/validate' -import { VISIBILITY_PROPERTY_ENUM } from '../../../constants/ProfileConstants' +import { saveProfileParameterVisibility } from '../../../service/ProfileService.js' +import { VISIBILITY_PROPERTY_ENUM } from '../../../constants/ProfileConstants.js' +import { handleError } from '../../../utils/handlers.ts' const { profileEnabled } = loadState('settings', 'personalInfoParameters', false) @@ -53,7 +35,7 @@ export default { name: 'VisibilityDropdown', components: { - Multiselect, + NcSelect, }, props: { @@ -111,7 +93,7 @@ export default { const { name: visibility } = visibilityObject this.$emit('update:visibility', visibility) - if (validateStringInput(visibility)) { + if (visibility !== '') { await this.updateVisibility(visibility) } } @@ -137,8 +119,7 @@ export default { // Ensure that local state reflects server state this.initialVisibility = visibility } else { - showError(errorMessage) - this.logger.error(errorMessage, error) + handleError(error, errorMessage) } }, @@ -152,7 +133,7 @@ export default { <style lang="scss" scoped> .visibility-container { display: flex; - width: max-content; + flex-wrap: wrap; &.disabled { filter: grayscale(1); @@ -161,7 +142,7 @@ export default { pointer-events: none; & *, - &::v-deep * { + &:deep(*) { cursor: default; pointer-events: none; } @@ -173,8 +154,8 @@ export default { line-height: 50px; } - &__multiselect { - width: 260px; + &__select { + width: 270px; max-width: 40vw; } } diff --git a/apps/settings/src/components/PersonalInfo/PronounsSection.vue b/apps/settings/src/components/PersonalInfo/PronounsSection.vue new file mode 100644 index 00000000000..e345cb8e225 --- /dev/null +++ b/apps/settings/src/components/PersonalInfo/PronounsSection.vue @@ -0,0 +1,47 @@ +<!-- + - SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <AccountPropertySection v-bind.sync="pronouns" + :placeholder="randomPronounsPlaceholder" /> +</template> + +<script lang="ts"> +import type { IAccountProperty } from '../../constants/AccountPropertyConstants.ts' + +import { loadState } from '@nextcloud/initial-state' +import { t } from '@nextcloud/l10n' +import { defineComponent } from 'vue' +import AccountPropertySection from './shared/AccountPropertySection.vue' +import { NAME_READABLE_ENUM } from '../../constants/AccountPropertyConstants.ts' + +const { pronouns } = loadState<{ pronouns: IAccountProperty }>('settings', 'personalInfoParameters') + +export default defineComponent({ + name: 'PronounsSection', + + components: { + AccountPropertySection, + }, + + data() { + return { + pronouns: { ...pronouns, readable: NAME_READABLE_ENUM[pronouns.name] }, + } + }, + + computed: { + randomPronounsPlaceholder() { + const pronouns = [ + t('settings', 'she/her'), + t('settings', 'he/him'), + t('settings', 'they/them'), + ] + const pronounsExample = pronouns[Math.floor(Math.random() * pronouns.length)] + return t('settings', 'Your pronouns. E.g. {pronounsExample}', { pronounsExample }) + }, + }, +}) +</script> diff --git a/apps/settings/src/components/PersonalInfo/RoleSection.vue b/apps/settings/src/components/PersonalInfo/RoleSection.vue new file mode 100644 index 00000000000..3581112fe1b --- /dev/null +++ b/apps/settings/src/components/PersonalInfo/RoleSection.vue @@ -0,0 +1,34 @@ +<!-- + - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <AccountPropertySection v-bind.sync="role" + autocomplete="organization-title" + :placeholder="t('settings', 'Your role')" /> +</template> + +<script> +import { loadState } from '@nextcloud/initial-state' + +import AccountPropertySection from './shared/AccountPropertySection.vue' + +import { NAME_READABLE_ENUM } from '../../constants/AccountPropertyConstants.js' + +const { role } = loadState('settings', 'personalInfoParameters', {}) + +export default { + name: 'RoleSection', + + components: { + AccountPropertySection, + }, + + data() { + return { + role: { ...role, readable: NAME_READABLE_ENUM[role.name] }, + } + }, +} +</script> diff --git a/apps/settings/src/components/PersonalInfo/RoleSection/Role.vue b/apps/settings/src/components/PersonalInfo/RoleSection/Role.vue deleted file mode 100644 index efbc06e61be..00000000000 --- a/apps/settings/src/components/PersonalInfo/RoleSection/Role.vue +++ /dev/null @@ -1,174 +0,0 @@ -<!-- - - @copyright 2021, Christopher Ng <chrng8@gmail.com> - - - - @author Christopher Ng <chrng8@gmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - ---> - -<template> - <div class="role"> - <input id="role" - type="text" - :placeholder="t('settings', 'Your role')" - :value="role" - autocapitalize="none" - autocomplete="on" - autocorrect="off" - @input="onRoleChange"> - - <div class="role__actions-container"> - <transition name="fade"> - <span v-if="showCheckmarkIcon" class="icon-checkmark" /> - <span v-else-if="showErrorIcon" class="icon-error" /> - </transition> - </div> - </div> -</template> - -<script> -import { showError } from '@nextcloud/dialogs' -import { emit } from '@nextcloud/event-bus' -import debounce from 'debounce' - -import { ACCOUNT_PROPERTY_ENUM } from '../../../constants/AccountPropertyConstants' -import { savePrimaryAccountProperty } from '../../../service/PersonalInfo/PersonalInfoService' - -export default { - name: 'Role', - - props: { - role: { - type: String, - required: true, - }, - scope: { - type: String, - required: true, - }, - }, - - data() { - return { - initialRole: this.role, - localScope: this.scope, - showCheckmarkIcon: false, - showErrorIcon: false, - } - }, - - methods: { - onRoleChange(e) { - this.$emit('update:role', e.target.value) - this.debounceRoleChange(e.target.value.trim()) - }, - - debounceRoleChange: debounce(async function(role) { - await this.updatePrimaryRole(role) - }, 500), - - async updatePrimaryRole(role) { - try { - const responseData = await savePrimaryAccountProperty(ACCOUNT_PROPERTY_ENUM.ROLE, role) - this.handleResponse({ - role, - status: responseData.ocs?.meta?.status, - }) - } catch (e) { - this.handleResponse({ - errorMessage: t('settings', 'Unable to update role'), - error: e, - }) - } - }, - - handleResponse({ role, status, errorMessage, error }) { - if (status === 'ok') { - // Ensure that local state reflects server state - this.initialRole = role - emit('settings:role:updated', role) - this.showCheckmarkIcon = true - setTimeout(() => { this.showCheckmarkIcon = false }, 2000) - } else { - showError(errorMessage) - this.logger.error(errorMessage, error) - this.showErrorIcon = true - setTimeout(() => { this.showErrorIcon = false }, 2000) - } - }, - - onScopeChange(scope) { - this.$emit('update:scope', scope) - }, - }, -} -</script> - -<style lang="scss" scoped> -.role { - display: grid; - align-items: center; - - input { - grid-area: 1 / 1; - width: 100%; - height: 34px; - margin: 3px 3px 3px 0; - padding: 7px 6px; - color: var(--color-main-text); - border: 1px solid var(--color-border-dark); - border-radius: var(--border-radius); - background-color: var(--color-main-background); - font-family: var(--font-face); - cursor: text; - } - - .role__actions-container { - grid-area: 1 / 1; - justify-self: flex-end; - height: 30px; - - display: flex; - gap: 0 2px; - margin-right: 5px; - - .icon-checkmark, - .icon-error { - height: 30px !important; - min-height: 30px !important; - width: 30px !important; - min-width: 30px !important; - top: 0; - right: 0; - float: none; - } - } -} - -.fade-enter, -.fade-leave-to { - opacity: 0; -} - -.fade-enter-active { - transition: opacity 200ms ease-out; -} - -.fade-leave-active { - transition: opacity 300ms ease-out; -} -</style> diff --git a/apps/settings/src/components/PersonalInfo/RoleSection/RoleSection.vue b/apps/settings/src/components/PersonalInfo/RoleSection/RoleSection.vue deleted file mode 100644 index 51026f4860c..00000000000 --- a/apps/settings/src/components/PersonalInfo/RoleSection/RoleSection.vue +++ /dev/null @@ -1,69 +0,0 @@ -<!-- - - @copyright 2021, Christopher Ng <chrng8@gmail.com> - - - - @author Christopher Ng <chrng8@gmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - ---> - -<template> - <section> - <HeaderBar :account-property="accountProperty" - label-for="role" - :scope.sync="primaryRole.scope" /> - - <Role :role.sync="primaryRole.value" - :scope.sync="primaryRole.scope" /> - </section> -</template> - -<script> -import { loadState } from '@nextcloud/initial-state' - -import Role from './Role' -import HeaderBar from '../shared/HeaderBar' - -import { ACCOUNT_PROPERTY_READABLE_ENUM } from '../../../constants/AccountPropertyConstants' - -const { roleMap: { primaryRole } } = loadState('settings', 'personalInfoParameters', {}) - -export default { - name: 'RoleSection', - - components: { - Role, - HeaderBar, - }, - - data() { - return { - accountProperty: ACCOUNT_PROPERTY_READABLE_ENUM.ROLE, - primaryRole, - } - }, -} -</script> - -<style lang="scss" scoped> -section { - padding: 10px 10px; - - &::v-deep button:disabled { - cursor: default; - } -} -</style> diff --git a/apps/settings/src/components/PersonalInfo/TwitterSection.vue b/apps/settings/src/components/PersonalInfo/TwitterSection.vue new file mode 100644 index 00000000000..43d08f81e3f --- /dev/null +++ b/apps/settings/src/components/PersonalInfo/TwitterSection.vue @@ -0,0 +1,34 @@ +<!-- + - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <AccountPropertySection v-bind.sync="value" + :readable="readable" + :on-validate="onValidate" + :placeholder="t('settings', 'Your X (formerly Twitter) handle')" /> +</template> + +<script setup lang="ts"> +import type { AccountProperties } from '../../constants/AccountPropertyConstants.js' + +import { loadState } from '@nextcloud/initial-state' +import { t } from '@nextcloud/l10n' +import { ref } from 'vue' +import { NAME_READABLE_ENUM } from '../../constants/AccountPropertyConstants.ts' +import AccountPropertySection from './shared/AccountPropertySection.vue' + +const { twitter } = loadState<AccountProperties>('settings', 'personalInfoParameters') + +const value = ref({ ...twitter }) +const readable = NAME_READABLE_ENUM[twitter.name] + +/** + * Validate that the text might be a twitter handle + * @param text The potential twitter handle + */ +function onValidate(text: string): boolean { + return text === '' || text.match(/^@?([a-zA-Z0-9_]{2,15})$/) !== null +} +</script> diff --git a/apps/settings/src/components/PersonalInfo/WebsiteSection.vue b/apps/settings/src/components/PersonalInfo/WebsiteSection.vue new file mode 100644 index 00000000000..762909139dd --- /dev/null +++ b/apps/settings/src/components/PersonalInfo/WebsiteSection.vue @@ -0,0 +1,43 @@ +<!-- + - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <AccountPropertySection v-bind.sync="website" + :placeholder="t('settings', 'Your website')" + autocomplete="url" + type="url" + :on-validate="onValidate" /> +</template> + +<script> +import { loadState } from '@nextcloud/initial-state' + +import AccountPropertySection from './shared/AccountPropertySection.vue' + +import { NAME_READABLE_ENUM } from '../../constants/AccountPropertyConstants.js' +import { validateUrl } from '../../utils/validate.js' + +const { website } = loadState('settings', 'personalInfoParameters', {}) + +export default { + name: 'WebsiteSection', + + components: { + AccountPropertySection, + }, + + data() { + return { + website: { ...website, readable: NAME_READABLE_ENUM[website.name] }, + } + }, + + methods: { + onValidate(value) { + return validateUrl(value) + }, + }, +} +</script> diff --git a/apps/settings/src/components/PersonalInfo/shared/AccountPropertySection.vue b/apps/settings/src/components/PersonalInfo/shared/AccountPropertySection.vue new file mode 100644 index 00000000000..d039641ec72 --- /dev/null +++ b/apps/settings/src/components/PersonalInfo/shared/AccountPropertySection.vue @@ -0,0 +1,243 @@ +<!-- + - SPDX-FileCopyrightText: 2022 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later +--> + +<template> + <section> + <HeaderBar :scope="scope" + :readable="readable" + :input-id="inputId" + :is-editable="isEditable" + @update:scope="(scope) => $emit('update:scope', scope)" /> + + <div v-if="isEditable" class="property"> + <NcTextArea v-if="multiLine" + :id="inputId" + autocapitalize="none" + autocomplete="off" + :error="hasError || !!helperText" + :helper-text="helperText" + label-outside + :placeholder="placeholder" + rows="8" + spellcheck="false" + :success="isSuccess" + :value.sync="inputValue" /> + <NcInputField v-else + :id="inputId" + ref="input" + autocapitalize="none" + :autocomplete="autocomplete" + :error="hasError || !!helperText" + :helper-text="helperText" + label-outside + :placeholder="placeholder" + spellcheck="false" + :success="isSuccess" + :type="type" + :value.sync="inputValue" /> + </div> + <span v-else> + {{ value || t('settings', 'No {property} set', { property: readable.toLocaleLowerCase() }) }} + </span> + </section> +</template> + +<script> +import debounce from 'debounce' +import NcInputField from '@nextcloud/vue/components/NcInputField' +import NcTextArea from '@nextcloud/vue/components/NcTextArea' + +import HeaderBar from './HeaderBar.vue' + +import { savePrimaryAccountProperty } from '../../../service/PersonalInfo/PersonalInfoService.js' +import { handleError } from '../../../utils/handlers.ts' + +export default { + name: 'AccountPropertySection', + + components: { + HeaderBar, + NcInputField, + NcTextArea, + }, + + props: { + name: { + type: String, + required: true, + }, + value: { + type: String, + required: true, + }, + scope: { + type: String, + required: true, + }, + readable: { + type: String, + required: true, + }, + placeholder: { + type: String, + required: true, + }, + type: { + type: String, + default: 'text', + }, + isEditable: { + type: Boolean, + default: true, + }, + multiLine: { + type: Boolean, + default: false, + }, + onValidate: { + type: Function, + default: null, + }, + onSave: { + type: Function, + default: null, + }, + autocomplete: { + type: String, + default: null, + }, + }, + + emits: ['update:scope', 'update:value'], + + data() { + return { + initialValue: this.value, + helperText: '', + isSuccess: false, + hasError: false, + } + }, + + computed: { + inputId() { + return `account-property-${this.name}` + }, + + inputValue: { + get() { + return this.value + }, + set(value) { + this.$emit('update:value', value) + this.debouncePropertyChange(value.trim()) + }, + }, + + debouncePropertyChange() { + return debounce(async function(value) { + this.helperText = this.$refs.input?.$refs.input?.validationMessage || '' + if (this.helperText !== '') { + return + } + this.hasError = this.onValidate && !this.onValidate(value) + if (this.hasError) { + this.helperText = t('settings', 'Invalid value') + return + } + await this.updateProperty(value) + }, 1000) + }, + }, + + methods: { + async updateProperty(value) { + try { + this.hasError = false + const responseData = await savePrimaryAccountProperty( + this.name, + value, + ) + this.handleResponse({ + value, + status: responseData.ocs?.meta?.status, + }) + } catch (e) { + this.handleResponse({ + errorMessage: t('settings', 'Unable to update {property}', { property: this.readable.toLocaleLowerCase() }), + error: e, + }) + } + }, + + handleResponse({ value, status, errorMessage, error }) { + if (status === 'ok') { + this.initialValue = value + if (this.onSave) { + this.onSave(value) + } + this.isSuccess = true + setTimeout(() => { this.isSuccess = false }, 2000) + } else { + handleError(error, errorMessage) + this.hasError = true + } + }, + }, +} +</script> + +<style lang="scss" scoped> +section { + padding: 10px 10px; + + .property { + display: flex; + flex-direction: row; + align-items: start; + gap: 4px; + + .property__actions-container { + margin-top: 6px; + justify-self: flex-end; + align-self: flex-end; + + display: flex; + gap: 0 2px; + margin-inline-end: 5px; + margin-bottom: 5px; + } + } + + .property__helper-text-message { + padding: 4px 0; + display: flex; + align-items: center; + + &__icon { + margin-inline-end: 8px; + align-self: start; + margin-top: 4px; + } + + &--error { + color: var(--color-error); + } + } + + .fade-enter, + .fade-leave-to { + opacity: 0; + } + + .fade-enter-active { + transition: opacity 200ms ease-out; + } + + .fade-leave-active { + transition: opacity 300ms ease-out; + } +} +</style> diff --git a/apps/settings/src/components/PersonalInfo/shared/FederationControl.vue b/apps/settings/src/components/PersonalInfo/shared/FederationControl.vue index b14bc5165b5..e55a50056d3 100644 --- a/apps/settings/src/components/PersonalInfo/shared/FederationControl.vue +++ b/apps/settings/src/components/PersonalInfo/shared/FederationControl.vue @@ -1,74 +1,72 @@ <!-- - - @copyright 2021, Christopher Ng <chrng8@gmail.com> - - - - @author Christopher Ng <chrng8@gmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - + - SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later --> <template> - <Actions :class="{ 'federation-actions': !additional, 'federation-actions--additional': additional }" + <NcActions ref="federationActions" + class="federation-actions" :aria-label="ariaLabel" - :default-icon="scopeIcon" :disabled="disabled"> - <FederationControlAction v-for="federationScope in federationScopes" + <template #icon> + <NcIconSvgWrapper :path="scopeIcon" /> + </template> + + <NcActionButton v-for="federationScope in federationScopes" :key="federationScope.name" - :active-scope="scope" - :display-name="federationScope.displayName" - :handle-scope-change="changeScope" - :icon-class="federationScope.iconClass" - :is-supported-scope="supportedScopes.includes(federationScope.name)" - :name="federationScope.name" - :tooltip-disabled="federationScope.tooltipDisabled" - :tooltip="federationScope.tooltip" /> - </Actions> + :close-after-click="true" + :disabled="!supportedScopes.includes(federationScope.name)" + :name="federationScope.displayName" + type="radio" + :value="federationScope.name" + :model-value="scope" + @update:modelValue="changeScope"> + <template #icon> + <NcIconSvgWrapper :path="federationScope.icon" /> + </template> + {{ supportedScopes.includes(federationScope.name) ? federationScope.tooltip : federationScope.tooltipDisabled }} + </NcActionButton> + </NcActions> </template> <script> -import Actions from '@nextcloud/vue/dist/Components/Actions' +import NcActions from '@nextcloud/vue/components/NcActions' +import NcActionButton from '@nextcloud/vue/components/NcActionButton' +import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' import { loadState } from '@nextcloud/initial-state' -import { showError } from '@nextcloud/dialogs' - -import FederationControlAction from './FederationControlAction' import { ACCOUNT_PROPERTY_READABLE_ENUM, + ACCOUNT_SETTING_PROPERTY_READABLE_ENUM, + PROFILE_READABLE_ENUM, PROPERTY_READABLE_KEYS_ENUM, PROPERTY_READABLE_SUPPORTED_SCOPES_ENUM, - SCOPE_ENUM, SCOPE_PROPERTY_ENUM, + SCOPE_PROPERTY_ENUM, + SCOPE_ENUM, UNPUBLISHED_READABLE_PROPERTIES, -} from '../../../constants/AccountPropertyConstants' -import { savePrimaryAccountPropertyScope } from '../../../service/PersonalInfo/PersonalInfoService' +} from '../../../constants/AccountPropertyConstants.js' +import { savePrimaryAccountPropertyScope } from '../../../service/PersonalInfo/PersonalInfoService.js' +import { handleError } from '../../../utils/handlers.ts' -const { lookupServerUploadEnabled } = loadState('settings', 'accountParameters', {}) +const { + federationEnabled, + lookupServerUploadEnabled, +} = loadState('settings', 'accountParameters', {}) export default { name: 'FederationControl', components: { - Actions, - FederationControlAction, + NcActions, + NcActionButton, + NcIconSvgWrapper, }, props: { - accountProperty: { + readable: { type: String, required: true, - validator: (value) => Object.values(ACCOUNT_PROPERTY_READABLE_ENUM).includes(value), + validator: (value) => Object.values(ACCOUNT_PROPERTY_READABLE_ENUM).includes(value) || Object.values(ACCOUNT_SETTING_PROPERTY_READABLE_ENUM).includes(value) || value === PROFILE_READABLE_ENUM.PROFILE_VISIBILITY, }, additional: { type: Boolean, @@ -92,20 +90,26 @@ export default { }, }, + emits: ['update:scope'], + data() { return { - accountPropertyLowerCase: this.accountProperty.toLocaleLowerCase(), + readableLowerCase: this.readable.toLocaleLowerCase(), initialScope: this.scope, } }, computed: { ariaLabel() { - return t('settings', 'Change scope level of {accountProperty}', { accountProperty: this.accountPropertyLowerCase }) + return t('settings', 'Change scope level of {property}, current scope is {scope}', { property: this.readableLowerCase, scope: this.scopeDisplayNameLowerCase }) + }, + + scopeDisplayNameLowerCase() { + return SCOPE_PROPERTY_ENUM[this.scope].displayName.toLocaleLowerCase() }, scopeIcon() { - return SCOPE_PROPERTY_ENUM[this.scope].iconClass + return SCOPE_PROPERTY_ENUM[this.scope].icon }, federationScopes() { @@ -113,15 +117,21 @@ export default { }, supportedScopes() { - if (lookupServerUploadEnabled && !UNPUBLISHED_READABLE_PROPERTIES.includes(this.accountProperty)) { - return [ - ...PROPERTY_READABLE_SUPPORTED_SCOPES_ENUM[this.accountProperty], - SCOPE_ENUM.FEDERATED, - SCOPE_ENUM.PUBLISHED, - ] + const scopes = PROPERTY_READABLE_SUPPORTED_SCOPES_ENUM[this.readable] + + if (UNPUBLISHED_READABLE_PROPERTIES.includes(this.readable)) { + return scopes + } + + if (federationEnabled) { + scopes.push(SCOPE_ENUM.FEDERATED) } - return PROPERTY_READABLE_SUPPORTED_SCOPES_ENUM[this.accountProperty] + if (lookupServerUploadEnabled) { + scopes.push(SCOPE_ENUM.PUBLISHED) + } + + return scopes }, }, @@ -134,18 +144,21 @@ export default { } else { await this.updateAdditionalScope(scope) } + + // TODO: provide focus method from NcActions + this.$refs.federationActions.$refs?.triggerButton?.$el?.focus?.() }, async updatePrimaryScope(scope) { try { - const responseData = await savePrimaryAccountPropertyScope(PROPERTY_READABLE_KEYS_ENUM[this.accountProperty], scope) + const responseData = await savePrimaryAccountPropertyScope(PROPERTY_READABLE_KEYS_ENUM[this.readable], scope) this.handleResponse({ scope, status: responseData.ocs?.meta?.status, }) } catch (e) { this.handleResponse({ - errorMessage: t('settings', 'Unable to update federation scope of the primary {accountProperty}', { accountProperty: this.accountPropertyLowerCase }), + errorMessage: t('settings', 'Unable to update federation scope of the primary {property}', { property: this.readableLowerCase }), error: e, }) } @@ -160,7 +173,7 @@ export default { }) } catch (e) { this.handleResponse({ - errorMessage: t('settings', 'Unable to update federation scope of additional {accountProperty}', { accountProperty: this.accountPropertyLowerCase }), + errorMessage: t('settings', 'Unable to update federation scope of additional {property}', { property: this.readableLowerCase }), error: e, }) } @@ -171,8 +184,7 @@ export default { this.initialScope = scope } else { this.$emit('update:scope', this.initialScope) - showError(errorMessage) - this.logger.error(errorMessage, error) + handleError(error, errorMessage) } }, }, @@ -180,25 +192,15 @@ export default { </script> <style lang="scss" scoped> - .federation-actions, - .federation-actions--additional { - opacity: 0.4 !important; - - &:hover, - &:focus, - &:active { - opacity: 0.8 !important; - } - } - - .federation-actions--additional { - &::v-deep button { +.federation-actions { + &--additional { + &:deep(button) { // TODO remove this hack - padding-bottom: 7px; height: 30px !important; min-height: 30px !important; width: 30px !important; min-width: 30px !important; } } +} </style> diff --git a/apps/settings/src/components/PersonalInfo/shared/FederationControlAction.vue b/apps/settings/src/components/PersonalInfo/shared/FederationControlAction.vue deleted file mode 100644 index f98d9bc7535..00000000000 --- a/apps/settings/src/components/PersonalInfo/shared/FederationControlAction.vue +++ /dev/null @@ -1,104 +0,0 @@ -<!-- - - @copyright 2021 Christopher Ng <chrng8@gmail.com> - - - - @author Christopher Ng <chrng8@gmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - ---> - -<template> - <ActionButton :aria-label="isSupportedScope ? tooltip : tooltipDisabled" - class="federation-actions__btn" - :class="{ 'federation-actions__btn--active': activeScope === name }" - :close-after-click="true" - :disabled="!isSupportedScope" - :icon="iconClass" - :title="displayName" - @click.stop.prevent="updateScope"> - {{ isSupportedScope ? tooltip : tooltipDisabled }} - </ActionButton> -</template> - -<script> -import ActionButton from '@nextcloud/vue/dist/Components/ActionButton' - -export default { - name: 'FederationControlAction', - - components: { - ActionButton, - }, - - props: { - activeScope: { - type: String, - required: true, - }, - displayName: { - type: String, - required: true, - }, - handleScopeChange: { - type: Function, - default: () => {}, - }, - iconClass: { - type: String, - required: true, - }, - isSupportedScope: { - type: Boolean, - required: true, - }, - name: { - type: String, - required: true, - }, - tooltipDisabled: { - type: String, - default: '', - }, - tooltip: { - type: String, - required: true, - }, - }, - - methods: { - updateScope() { - this.handleScopeChange(this.name) - }, - }, -} -</script> - -<style lang="scss" scoped> - .federation-actions__btn { - &::v-deep p { - width: 150px !important; - padding: 8px 0 !important; - color: var(--color-main-text) !important; - font-size: 12.8px !important; - line-height: 1.5em !important; - } - } - - .federation-actions__btn--active { - background-color: var(--color-primary-light) !important; - box-shadow: inset 2px 0 var(--color-primary) !important; - } -</style> diff --git a/apps/settings/src/components/PersonalInfo/shared/HeaderBar.vue b/apps/settings/src/components/PersonalInfo/shared/HeaderBar.vue index 65eb5a110a3..7c95c2b8f4c 100644 --- a/apps/settings/src/components/PersonalInfo/shared/HeaderBar.vue +++ b/apps/settings/src/components/PersonalInfo/shared/HeaderBar.vue @@ -1,41 +1,28 @@ <!-- - - @copyright 2021, Christopher Ng <chrng8@gmail.com> - - - - @author Christopher Ng <chrng8@gmail.com> - - - - @license GNU AGPL version 3 or any later version - - - - This program is free software: you can redistribute it and/or modify - - it under the terms of the GNU Affero General Public License as - - published by the Free Software Foundation, either version 3 of the - - License, or (at your option) any later version. - - - - This program is distributed in the hope that it will be useful, - - but WITHOUT ANY WARRANTY; without even the implied warranty of - - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - - GNU Affero General Public License for more details. - - - - You should have received a copy of the GNU Affero General Public License - - along with this program. If not, see <http://www.gnu.org/licenses/>. - - + - SPDX-FileCopyrightText: 2021 Nextcloud GmbH and Nextcloud contributors + - SPDX-License-Identifier: AGPL-3.0-or-later --> <template> - <h3 :class="{ 'setting-property': isSettingProperty, 'profile-property': isProfileProperty }"> - <label :for="labelFor"> + <div class="headerbar-label" :class="{ 'setting-property': isSettingProperty, 'profile-property': isProfileProperty }"> + <h3 v-if="isHeading" class="headerbar__heading"> <!-- Already translated as required by prop validator --> - {{ accountProperty }} + {{ readable }} + </h3> + <label v-else :for="inputId"> + <!-- Already translated as required by prop validator --> + {{ readable }} </label> <template v-if="scope"> <FederationControl class="federation-control" - :account-property="accountProperty" + :readable="readable" :scope.sync="localScope" @update:scope="onScopeChange" /> </template> <template v-if="isEditable && isMultiValueSupported"> - <Button type="tertiary" + <NcButton type="tertiary" :disabled="!isValidSection" :aria-label="t('settings', 'Add additional email')" @click.stop.prevent="onAddAdditional"> @@ -43,31 +30,43 @@ <Plus :size="20" /> </template> {{ t('settings', 'Add') }} - </Button> + </NcButton> </template> - </h3> + </div> </template> <script> -import FederationControl from './FederationControl' -import Button from '@nextcloud/vue/dist/Components/Button' -import Plus from 'vue-material-design-icons/Plus' -import { ACCOUNT_PROPERTY_READABLE_ENUM, ACCOUNT_SETTING_PROPERTY_READABLE_ENUM, PROFILE_READABLE_ENUM } from '../../../constants/AccountPropertyConstants' +import NcButton from '@nextcloud/vue/components/NcButton' +import Plus from 'vue-material-design-icons/Plus.vue' + +import FederationControl from './FederationControl.vue' + +import { + ACCOUNT_PROPERTY_READABLE_ENUM, + PROFILE_READABLE_ENUM, +} from '../../../constants/AccountPropertyConstants.js' export default { name: 'HeaderBar', components: { FederationControl, - Button, + NcButton, Plus, }, props: { - accountProperty: { + scope: { + type: String, + default: null, + }, + readable: { type: String, required: true, - validator: (value) => Object.values(ACCOUNT_PROPERTY_READABLE_ENUM).includes(value) || Object.values(ACCOUNT_SETTING_PROPERTY_READABLE_ENUM).includes(value) || value === PROFILE_READABLE_ENUM.PROFILE_VISIBILITY, + }, + inputId: { + type: String, + default: null, }, isEditable: { type: Boolean, @@ -79,15 +78,11 @@ export default { }, isValidSection: { type: Boolean, - default: false, - }, - labelFor: { - type: String, - default: '', + default: true, }, - scope: { - type: String, - default: null, + isHeading: { + type: Boolean, + default: false, }, }, @@ -99,11 +94,11 @@ export default { computed: { isProfileProperty() { - return this.accountProperty === ACCOUNT_PROPERTY_READABLE_ENUM.PROFILE_ENABLED + return this.readable === ACCOUNT_PROPERTY_READABLE_ENUM.PROFILE_ENABLED }, isSettingProperty() { - return Object.values(ACCOUNT_SETTING_PROPERTY_READABLE_ENUM).includes(this.accountProperty) + return !Object.values(ACCOUNT_PROPERTY_READABLE_ENUM).includes(this.readable) && !Object.values(PROFILE_READABLE_ENUM).includes(this.readable) }, }, @@ -120,10 +115,13 @@ export default { </script> <style lang="scss" scoped> - h3 { + .headerbar-label { + font-weight: normal; display: inline-flex; width: 100%; margin: 12px 0 0 0; + gap: 8px; + align-items: center; font-size: 16px; color: var(--color-text-light); @@ -132,7 +130,7 @@ export default { } &.setting-property { - height: 32px; + height: 34px; } label { @@ -140,11 +138,16 @@ export default { } } + .headerbar__heading { + margin: 0; + } + .federation-control { - margin: -12px 0 0 8px; + margin: 0; } .button-vue { - margin: -6px 0 0 auto !important; + margin: 0 !important; + margin-inline-start: auto !important; } </style> |