diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/components/image_cropper/image_cropper.js | 88 | ||||
| -rw-r--r-- | src/components/image_cropper/image_cropper.vue | 39 | ||||
| -rw-r--r-- | src/components/modal/modal.js | 17 | ||||
| -rw-r--r-- | src/components/modal/modal.vue | 101 | ||||
| -rw-r--r-- | src/components/settings/settings.vue | 14 | ||||
| -rw-r--r-- | src/components/user_settings/user_settings.js | 29 | ||||
| -rw-r--r-- | src/components/user_settings/user_settings.vue | 52 | ||||
| -rw-r--r-- | src/i18n/en.json | 2 |
8 files changed, 294 insertions, 48 deletions
diff --git a/src/components/image_cropper/image_cropper.js b/src/components/image_cropper/image_cropper.js new file mode 100644 index 00000000..e4bf5ea2 --- /dev/null +++ b/src/components/image_cropper/image_cropper.js @@ -0,0 +1,88 @@ +import Cropper from 'cropperjs' +import Modal from '../modal/modal.vue' +import 'cropperjs/dist/cropper.css' + +const ImageCropper = { + props: { + trigger: { + type: [String, Element], + required: true + }, + cropperOptions: { + type: Object, + default () { + return { + aspectRatio: 1, + autoCropArea: 1, + viewMode: 1, + movable: false, + zoomable: false, + guides: false + } + } + }, + mimes: { + type: String, + default: 'image/png, image/gif, image/jpeg, image/bmp, image/x-icon' + }, + title: { + type: String, + default: 'Crop picture' + }, + saveButtonLabel: { + type: String, + default: 'Save' + } + }, + data () { + return { + cropper: undefined, + dataUrl: undefined, + filename: undefined + } + }, + components: { + Modal + }, + methods: { + destroy () { + this.cropper.destroy() + this.$refs.input.value = '' + this.dataUrl = undefined + }, + submit () { + this.$emit('submit', this.cropper, this.filename) + this.destroy() + }, + pickImage () { + this.$refs.input.click() + }, + createCropper () { + this.cropper = new Cropper(this.$refs.img, this.cropperOptions) + } + }, + mounted () { + // listen for click event on trigger + let trigger = typeof this.trigger === 'object' ? this.trigger : document.querySelector(this.trigger) + if (!trigger) { + this.$emit('error', 'No image make trigger found.', 'user') + } else { + trigger.addEventListener('click', this.pickImage) + } + // listen for input file changes + let fileInput = this.$refs.input + fileInput.addEventListener('change', () => { + if (fileInput.files != null && fileInput.files[0] != null) { + let reader = new FileReader() + reader.onload = (e) => { + this.dataUrl = e.target.result + } + reader.readAsDataURL(fileInput.files[0]) + this.filename = fileInput.files[0].name || 'unknown' + this.$emit('changed', fileInput.files[0], reader) + } + }) + } +} + +export default ImageCropper diff --git a/src/components/image_cropper/image_cropper.vue b/src/components/image_cropper/image_cropper.vue new file mode 100644 index 00000000..1c52842c --- /dev/null +++ b/src/components/image_cropper/image_cropper.vue @@ -0,0 +1,39 @@ +<template> + <div class="image-cropper"> + <modal :show="dataUrl" :title="title" @close="destroy"> + <div class="modal-body"> + <div class="image-cropper-image-container"> + <img ref="img" :src="dataUrl" alt="" @load.stop="createCropper" /> + </div> + </div> + <div class="modal-footer"> + <button class="btn image-cropper-btn" type="button" @click="submit" v-text="saveButtonLabel"></button> + </div> + </modal> + <input ref="input" type="file" class="image-cropper-img-input" :accept="mimes"> + </div> +</template> + +<script src="./image_cropper.js"></script> + +<style lang="scss"> +.image-cropper { + &-img-input { + display: none; + } + + &-image-container { + position: relative; + + img { + display: block; + max-width: 100%; + } + } + + &-btn { + display: block; + width: 100%; + } +} +</style> diff --git a/src/components/modal/modal.js b/src/components/modal/modal.js new file mode 100644 index 00000000..36cd7f4a --- /dev/null +++ b/src/components/modal/modal.js @@ -0,0 +1,17 @@ +const Modal = { + props: ['show', 'title'], + methods: { + close: function () { + this.$emit('close') + } + }, + mounted: function () { + document.addEventListener('keydown', (e) => { + if (this.show && e.keyCode === 27) { + this.close() + } + }) + } +} + +export default Modal diff --git a/src/components/modal/modal.vue b/src/components/modal/modal.vue new file mode 100644 index 00000000..2e3cbe75 --- /dev/null +++ b/src/components/modal/modal.vue @@ -0,0 +1,101 @@ +<template> + <transition name="modal"> + <div class="modal-mask" @click="close" v-if="show"> + <div class="modal-container" @click.stop> + <div class="modal-header"> + <h3 class="modal-title">{{title}}</h3> + </div> + <slot></slot> + <a class="modal-close" @click="close"><i class="icon-cancel"></i></a> + </div> + </div> + </transition> +</template> + +<script src="./modal.js"></script> + +<style lang="scss"> +.modal-mask { + position: fixed; + z-index: 9998; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(27,31,35,.5); + transition: opacity .3s ease; +} + +.modal-container { + position: relative; + display: flex; + flex-direction: column; + width: 450px; + margin: 10vh auto; + max-height: 80vh; + max-width: 90vw; + background-color: #fff; + border: 1px solid #444d56; + border-radius: 3px; + box-shadow: 0 0 18px rgba(0,0,0,.4); + transition: all .3s ease; +} + +.modal-header { + flex: none; + background-color: #f6f8fa; + border-bottom: 1px solid #d1d5da; + border-top-left-radius: 2px; + border-top-right-radius: 2px; + padding: 16px; + margin: 0; +} + +h3.modal-title { + font-size: 14px; + font-weight: 600; + color: #24292e; + margin: 0; +} + +.modal-close { + position: absolute; + top: 0; + right: 0; + padding: 16px; + cursor: pointer; +} + +.modal-body { + border-bottom: 1px solid #e1e4e8; + padding: 16px; + overflow-y: auto; +} + +.modal-footer { + flex: none; + border-top: 1px solid #e1e4e8; + margin-top: -1px; + padding: 16px; +} + +/* + * The following styles are auto-applied to elements with + * transition="modal" when their visibility is toggled + * by Vue.js. + */ + +.modal-enter { + opacity: 0; +} + +.modal-leave-active { + opacity: 0; +} + +.modal-enter .modal-container, +.modal-leave-active .modal-container { + -webkit-transform: scale(1.1); + transform: scale(1.1); +} +</style> diff --git a/src/components/settings/settings.vue b/src/components/settings/settings.vue index dfb2e49d..91232382 100644 --- a/src/components/settings/settings.vue +++ b/src/components/settings/settings.vue @@ -311,20 +311,6 @@ color: $fallback--cRed; } - .old-avatar { - width: 128px; - border-radius: $fallback--avatarRadius; - border-radius: var(--avatarRadius, $fallback--avatarRadius); - } - - .new-avatar { - object-fit: cover; - width: 128px; - height: 128px; - border-radius: $fallback--avatarRadius; - border-radius: var(--avatarRadius, $fallback--avatarRadius); - } - .btn { min-height: 28px; min-width: 10em; diff --git a/src/components/user_settings/user_settings.js b/src/components/user_settings/user_settings.js index d20bf308..b3d31d67 100644 --- a/src/components/user_settings/user_settings.js +++ b/src/components/user_settings/user_settings.js @@ -1,6 +1,7 @@ import { unescape } from 'lodash' import TabSwitcher from '../tab_switcher/tab_switcher.js' +import ImageCropper from '../image_cropper/image_cropper.vue' import StyleSwitcher from '../style_switcher/style_switcher.vue' import fileSizeFormatService from '../../services/file_size_format/file_size_format.js' @@ -24,7 +25,6 @@ const UserSettings = { bannerUploading: false, backgroundUploading: false, followListUploading: false, - avatarPreview: null, bannerPreview: null, backgroundPreview: null, avatarUploadError: null, @@ -41,7 +41,8 @@ const UserSettings = { }, components: { StyleSwitcher, - TabSwitcher + TabSwitcher, + ImageCropper }, computed: { user () { @@ -117,31 +118,13 @@ const UserSettings = { } reader.readAsDataURL(file) }, - submitAvatar () { - if (!this.avatarPreview) { return } - - let img = this.avatarPreview - // eslint-disable-next-line no-undef - let imginfo = new Image() - let cropX, cropY, cropW, cropH - imginfo.src = img - if (imginfo.height > imginfo.width) { - cropX = 0 - cropW = imginfo.width - cropY = Math.floor((imginfo.height - imginfo.width) / 2) - cropH = imginfo.width - } else { - cropY = 0 - cropH = imginfo.height - cropX = Math.floor((imginfo.width - imginfo.height) / 2) - cropW = imginfo.height - } + submitAvatar (cropper) { + const img = cropper.getCroppedCanvas({ width: 150, height: 150 }).toDataURL('image/jpeg') this.avatarUploading = true - this.$store.state.api.backendInteractor.updateAvatar({params: {img, cropX, cropY, cropW, cropH}}).then((user) => { + this.$store.state.api.backendInteractor.updateAvatar({ params: { img } }).then((user) => { if (!user.error) { this.$store.commit('addNewUsers', [user]) this.$store.commit('setCurrentUser', user) - this.avatarPreview = null } else { this.avatarUploadError = this.$t('upload.error.base') + user.error } diff --git a/src/components/user_settings/user_settings.vue b/src/components/user_settings/user_settings.vue index d2381da2..9fcd3752 100644 --- a/src/components/user_settings/user_settings.vue +++ b/src/components/user_settings/user_settings.vue @@ -47,20 +47,20 @@ <div class="setting-item"> <h2>{{$t('settings.avatar')}}</h2> <p class="visibility-notice">{{$t('settings.avatar_size_instruction')}}</p> - <p>{{$t('settings.current_avatar')}}</p> - <img :src="user.profile_image_url_original" class="old-avatar"></img> - <p>{{$t('settings.set_new_avatar')}}</p> - <img class="new-avatar" v-bind:src="avatarPreview" v-if="avatarPreview"> - </img> - <div> - <input type="file" @change="uploadFile('avatar', $event)" ></input> + <div class="avatar-upload-wrapper"> + <div class="avatar-upload"> + <img :src="user.profile_image_url_original" class="avatar" /> + <div class="avatar-upload-loading-wrapper" v-if="avatarUploading"> + <i class="icon-spin4 animate-spin"></i> + </div> + </div> </div> - <i class="icon-spin4 animate-spin" v-if="avatarUploading"></i> - <button class="btn btn-default" v-else-if="avatarPreview" @click="submitAvatar">{{$t('general.submit')}}</button> - <div class='alert error' v-if="avatarUploadError"> + <button class="btn" type="button" id="pick-avatar" :disabled="avatarUploading">{{$t('settings.set_new_avatar')}}</button> + <div class="alert error" v-if="avatarUploadError"> Error: {{ avatarUploadError }} <i class="button-icon icon-cancel" @click="clearUploadError('avatar')"></i> </div> + <image-cropper trigger="#pick-avatar" :title="$t('settings.crop_your_new_avatar')" :saveButtonLabel="$t('settings.set_new_avatar')" @submit="submitAvatar" /> </div> <div class="setting-item"> <h2>{{$t('settings.profile_banner')}}</h2> @@ -167,6 +167,8 @@ </script> <style lang="scss"> +@import '../../_variables.scss'; + .profile-edit { .bio { margin: 0; @@ -193,5 +195,35 @@ .bg { max-width: 100%; } + + .avatar-upload { + display: inline-block; + position: relative; + } + + .avatar-upload-loading-wrapper { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + justify-content: center; + align-items: center; + background: rgba(0,0,0,.3); + + i { + font-size: 50px; + color: #FFF; + } + } + + .avatar { + display: block; + width: 150px; + height: 150px; + border-radius: $fallback--avatarRadius; + border-radius: var(--avatarRadius, $fallback--avatarRadius); + } } </style> diff --git a/src/i18n/en.json b/src/i18n/en.json index c664fbfa..90132e3e 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -112,7 +112,7 @@ "collapse_subject": "Collapse posts with subjects", "composing": "Composing", "confirm_new_password": "Confirm new password", - "current_avatar": "Your current avatar", + "crop_your_new_avatar": "Crop your new avatar", "current_password": "Current password", "current_profile_banner": "Your current profile banner", "data_import_export_tab": "Data Import / Export", |
