diff options
Diffstat (limited to 'apps/settings/src/components/PersonalInfo/AvatarSection.vue')
-rw-r--r-- | apps/settings/src/components/PersonalInfo/AvatarSection.vue | 333 |
1 files changed, 333 insertions, 0 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..f0ad1b68d3b --- /dev/null +++ b/apps/settings/src/components/PersonalInfo/AvatarSection.vue @@ -0,0 +1,333 @@ +<!-- + - @copyright 2022 Christopher Ng <chrng8@gmail.com> + - + - @author Christopher Ng <chrng8@gmail.com> + - + - @license AGPL-3.0-or-later + - + - 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 :input-id="avatarChangeSupported ? inputId : null" + :readable="avatar.readable" + :scope.sync="avatar.scope" /> + + <div v-if="!showCropper" class="avatar__container"> + <div class="avatar__preview"> + <NcAvatar v-if="!loading" + :user="userId" + :aria-label="t('settings', 'Your profile picture')" + :disabled-menu="true" + :disabled-tooltip="true" + :show-user-status="false" + :size="180" + :key="version" /> + <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', 'png or jpg, max. 20 MB') }}</span> + <input ref="input" + :id="inputId" + 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/dist/Components/NcAvatar' +import NcButton from '@nextcloud/vue/dist/Components/NcButton' +import VueCropper from 'vue-cropperjs' +// eslint-disable-next-line node/no-extraneous-import +import 'cropperjs/dist/cropper.css' + +import Upload from 'vue-material-design-icons/Upload' +import Folder from 'vue-material-design-icons/Folder' +import Delete from 'vue-material-design-icons/Delete' + +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) + .setModal(true) + .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) + }, + + computed: { + inputId() { + return `account-property-${this.avatar.name}` + }, + }, + + 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 + + this.$refs.cropper.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) + /** + * FIXME refresh all other avatars on the page when updated, + * the NcAvatar component itself should listen to the + * global events and optionally live refresh with a prop toggle + * https://github.com/nextcloud/nextcloud-vue/issues/2975 + */ + }, + + handleDisplayNameUpdate() { + this.version = oc_userconfig.avatar.version + }, + }, +} +</script> + +<style lang="scss" scoped> +.avatar { + &__container { + margin: 0 auto; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 16px 0; + width: 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; + } + + &::v-deep .cropper-view-box { + border-radius: 50%; + } + } +} + +input[type="file"] { + display: none; +} +</style> |