More avatar providers (#200)

Reload the avatar after changing it

Hide cropper after upload

Fix aspect ratio

Add loading variable

Move avatar settings to seperate component

Add avatar crop

Fix avatar upload

Add avatar file upload

Add abstract methods for file upload

Add saving avatar status

Add avatar setting

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: vikunja/frontend#200
This commit is contained in:
konrad 2020-08-02 17:17:29 +00:00
parent 617bcea04e
commit ec1b039daa
11 changed files with 295 additions and 58 deletions

View File

@ -20,6 +20,7 @@
"v-tooltip": "2.0.3",
"verte": "0.0.12",
"vue": "2.6.11",
"vue-advanced-cropper": "^0.16.10",
"vue-drag-resize": "1.4.2",
"vue-easymde": "1.2.2",
"vue-shortkey": "3.1.7",

View File

@ -44,7 +44,7 @@
<a @click="refreshApp()" class="button is-primary noshadow">Update Now</a>
</div>
<div class="user">
<img :src="userInfo.getAvatarUrl()" class="avatar" alt=""/>
<img :src="userAvatar" class="avatar" alt=""/>
<div class="dropdown is-right is-active">
<div class="dropdown-trigger">
<button class="button noshadow" @click.stop="userMenuActive = !userMenuActive">
@ -415,6 +415,7 @@
},
computed: mapState({
userInfo: state => state.auth.info,
userAvatar: state => state.auth.avatarUrl,
userAuthenticated: state => state.auth.authenticated,
motd: state => state.config.motd,
online: ONLINE,

View File

@ -0,0 +1,149 @@
<template>
<div class="card">
<header class="card-header">
<p class="card-header-title">
Avatar
</p>
</header>
<div class="card-content">
<div class="control mb-4">
<label class="radio">
<input type="radio" name="avatarProvider" v-model="avatarProvider" value="default"/>
Default
</label>
<label class="radio">
<input type="radio" name="avatarProvider" v-model="avatarProvider" value="initials"/>
Initials
</label>
<label class="radio">
<input type="radio" name="avatarProvider" v-model="avatarProvider" value="gravatar"/>
Gravatar
</label>
<label class="radio">
<input type="radio" name="avatarProvider" v-model="avatarProvider" value="upload"/>
Upload
</label>
</div>
<template v-if="avatarProvider === 'upload'">
<input
type="file"
ref="avatarUploadInput"
@change="cropAvatar"
class="is-hidden"
accept="image/*"
/>
<a
v-if="!isCropAvatar"
class="button is-primary"
@click="$refs.avatarUploadInput.click()"
:class="{ 'is-loading': avatarService.loading || loading}">
Upload Avatar
</a>
<template v-else>
<cropper
:src="avatarToCrop"
class="mb-4"
@ready="() => loading = false"
:stencil-props="{aspectRatio: 1}"
ref="cropper"/>
<a
class="button is-primary"
@click="uploadAvatar"
:class="{ 'is-loading': avatarService.loading || loading}">
Upload Avatar
</a>
</template>
</template>
<div class="bigbuttons" v-if="avatarProvider !== 'upload'">
<button @click="updateAvatarStatus()" class="button is-primary is-fullwidth"
:class="{ 'is-loading': avatarService.loading || loading}">
Save
</button>
</div>
</div>
</div>
</template>
<script>
import {Cropper} from 'vue-advanced-cropper'
import AvatarService from '../../services/avatar'
import AvatarModel from '../../models/avatar'
export default {
name: 'avatar-settings',
data() {
return {
avatarProvider: '',
avatarService: AvatarService,
isCropAvatar: false,
avatarToCrop: null,
loading: false, // Seperate variable because some things we're doing in browser take a bit
}
},
created() {
this.avatarService = new AvatarService()
this.avatarStatus()
},
components: {
Cropper,
},
methods: {
avatarStatus() {
this.avatarService.get({})
.then(r => {
this.avatarProvider = r.avatarProvider
})
.catch(e => this.error(e, this))
},
updateAvatarStatus() {
const avatarStatus = new AvatarModel({avatarProvider: this.avatarProvider})
this.avatarService.update(avatarStatus)
.then(() => {
this.success({message: 'Avatar status was updated successfully!'}, this)
this.$store.commit('auth/reloadAvatar')
})
.catch(e => this.error(e, this))
},
uploadAvatar() {
this.loading = true
const {canvas} = this.$refs.cropper.getResult()
if (canvas) {
canvas.toBlob(blob => {
this.avatarService.create(blob)
.then(() => {
this.success({message: 'The avatar has been set successfully!'}, this)
this.$store.commit('auth/reloadAvatar')
})
.catch(e => this.error(e, this))
.finally(() => {
this.loading = false
this.isCropAvatar = false
})
})
} else {
this.loading = false
}
},
cropAvatar() {
const avatar = this.$refs.avatarUploadInput.files
if (avatar.length === 0) {
return
}
this.loading = true
const reader = new FileReader()
reader.onload = e => {
this.avatarToCrop = e.target.result
this.isCropAvatar = true
}
reader.onloadend = () => this.loading = false
reader.readAsDataURL(avatar[0])
},
},
}
</script>

9
src/models/avatar.js Normal file
View File

@ -0,0 +1,9 @@
import AbstractModel from './abstractModel'
export default class AvatarModel extends AbstractModel {
defaults() {
return {
avatarProvider: '',
}
}
}

View File

@ -1,6 +1,6 @@
import axios from 'axios'
import {reduce, replace} from 'lodash'
import { objectToSnakeCase } from '../helpers/case'
import {objectToSnakeCase} from '../helpers/case'
export default class AbstractService {
@ -10,6 +10,7 @@ export default class AbstractService {
http = null
loading = false
uploadProgress = 0
paths = {
create: '',
get: '',
@ -134,10 +135,10 @@ export default class AbstractService {
pattern = new RegExp(pattern instanceof RegExp ? pattern.source : pattern, 'g')
for (let parameter; (parameter = pattern.exec(route)) !== null;) {
replace$$1[parameter[0]] = parameters[parameter[1]];
replace$$1[parameter[0]] = parameters[parameter[1]]
}
return replace$$1;
return replace$$1
}
/**
@ -443,4 +444,62 @@ export default class AbstractService {
cancel()
})
}
/**
* Uploads a file to a url.
* @param url
* @param file
* @param fieldName The name of the field the file is uploaded to.
* @returns {Q.Promise<unknown>}
*/
uploadFile(url, file, fieldName) {
return this.uploadBlob(url, new Blob([file]), fieldName, file.name)
}
/**
* Uploads a blob to a url.
* @param url
* @param blob
* @param fieldName
* @param filename
* @returns {Q.Promise<unknown>}
*/
uploadBlob(url, blob, fieldName, filename) {
const data = new FormData()
data.append(fieldName, blob, filename)
return this.uploadFormData(url, data)
}
/**
* Uploads a form data object.
* @param url
* @param formData
* @returns {Q.Promise<unknown>}
*/
uploadFormData(url, formData) {
const cancel = this.setLoading()
return this.http.put(
url,
formData,
{
headers: {
'Content-Type':
'multipart/form-data; boundary=' + formData._boundary,
},
onUploadProgress: progressEvent => {
this.uploadProgress = Math.round((progressEvent.loaded * 100) / progressEvent.total)
},
},
)
.catch(error => {
return this.errorHandler(error)
})
.then(response => {
return Promise.resolve(this.modelCreateFactory(response.data))
})
.finally(() => {
this.uploadProgress = 0
cancel()
})
}
}

View File

@ -16,8 +16,6 @@ export default class AttachmentService extends AbstractService {
return model
}
uploadProgress = 0
useCreateInterceptor() {
return false
}
@ -61,36 +59,15 @@ export default class AttachmentService extends AbstractService {
* @returns {Promise<any|never>}
*/
create(model, files) {
let data = new FormData()
const data = new FormData()
for (let i = 0; i < files.length; i++) {
// TODO: Validation of file size
data.append('files', new Blob([files[i]]), files[i].name);
}
const cancel = this.setLoading()
return this.http.put(
return this.uploadFormData(
this.getReplacedRoute(this.paths.create, model),
data,
{
headers: {
'Content-Type':
'multipart/form-data; boundary=' + data._boundary,
},
onUploadProgress: progressEvent => {
this.uploadProgress = Math.round( (progressEvent.loaded * 100) / progressEvent.total );
}
}
data
)
.catch(error => {
return this.errorHandler(error)
})
.then(response => {
return Promise.resolve(this.modelCreateFactory(response.data))
})
.finally(() => {
this.uploadProgress = 0
cancel()
})
}
}

29
src/services/avatar.js Normal file
View File

@ -0,0 +1,29 @@
import AbstractService from './abstractService'
import AvatarModel from '../models/avatar'
export default class AvatarService extends AbstractService {
constructor() {
super({
get: '/user/settings/avatar',
update: '/user/settings/avatar',
create: '/user/settings/avatar/upload',
})
}
modelFactory(data) {
return new AvatarModel(data)
}
useCreateInterceptor() {
return false
}
create(blob) {
return this.uploadBlob(
this.paths.create,
blob,
'avatar',
'avatar.jpg', // This fails without a file name
)
}
}

View File

@ -8,8 +8,6 @@ export default class BackgroundUploadService extends AbstractService {
})
}
uploadProgress = 0
useCreateInterceptor() {
return false
}
@ -25,33 +23,10 @@ export default class BackgroundUploadService extends AbstractService {
* @returns {Promise<any|never>}
*/
create(listId, file) {
let data = new FormData()
data.append('background', new Blob([file]), file.name);
const cancel = this.setLoading()
return this.http.put(
return this.uploadFile(
this.getReplacedRoute(this.paths.create, {listId: listId}),
data,
{
headers: {
'Content-Type':
'multipart/form-data; boundary=' + data._boundary,
},
onUploadProgress: progressEvent => {
this.uploadProgress = Math.round((progressEvent.loaded * 100) / progressEvent.total);
}
}
file,
'background'
)
.catch(error => {
return this.errorHandler(error)
})
.then(response => {
return Promise.resolve(this.modelCreateFactory(response.data))
})
.finally(() => {
this.uploadProgress = 0
cancel()
})
}
}

View File

@ -9,10 +9,12 @@ export default {
isLinkShareAuth: false,
info: {},
needsTotpPasscode: false,
avatarUrl: '',
}),
mutations: {
info(state, info) {
state.info = info
state.avatarUrl = info.getAvatarUrl()
},
authenticated(state, authenticated) {
state.authenticated = authenticated
@ -23,6 +25,9 @@ export default {
needsTotpPasscode(state, needs) {
state.needsTotpPasscode = needs
},
reloadAvatar(state) {
state.avatarUrl = `${state.info.getAvatarUrl()}&=${+new Date()}`
},
},
actions: {
// Logs a user in with a set of credentials.

View File

@ -106,6 +106,9 @@
</div>
</div>
<!-- Avatar -->
<avatar-settings/>
<!-- TOTP -->
<div class="card" v-if="totpEnabled">
<header class="card-header">
@ -200,6 +203,8 @@
import {mapState} from 'vuex'
import AvatarSettings from '../../components/user/avatar-settings'
export default {
name: 'Settings',
data() {
@ -220,6 +225,9 @@
totpDisablePassword: '',
}
},
components: {
AvatarSettings,
},
created() {
this.passwordUpdateService = new PasswordUpdateService()
this.passwordUpdate = new PasswordUpdateModel()

View File

@ -3881,6 +3881,11 @@ class-utils@^0.3.5:
isobject "^3.0.0"
static-extend "^0.1.1"
classnames@^2.2.6:
version "2.2.6"
resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce"
integrity sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q==
clean-css@4.2.x:
version "4.2.1"
resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-4.2.1.tgz#2d411ef76b8569b6d0c84068dabe85b0aa5e5c17"
@ -4737,6 +4742,11 @@ de-indent@^1.0.2:
resolved "https://registry.yarnpkg.com/de-indent/-/de-indent-1.0.2.tgz#b2038e846dc33baa5796128d0804b455b8c1e21d"
integrity sha1-sgOOhG3DO6pXlhKNCAS0VbjB4h0=
debounce@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.0.tgz#44a540abc0ea9943018dc0eaa95cce87f65cd131"
integrity sha512-mYtLl1xfZLi1m4RtQYlZgJUNQjl4ZxVnHzIR8nLLgi4q1YT8o/WM+MK/f8yfcc9s5Ir5zRaPZyZU6xs1Syoocg==
debug@2.6.9, debug@^2.2.0, debug@^2.3.3:
version "2.6.9"
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
@ -5179,6 +5189,11 @@ duplexify@^3.4.2, duplexify@^3.6.0:
readable-stream "^2.0.0"
stream-shift "^1.0.0"
easy-bem@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/easy-bem/-/easy-bem-1.0.2.tgz#3f759158c045465744900aa26ba8bd7d88d7a969"
integrity sha512-tHtLDhcEHZIMKdiiZElQoR8TcZ/6rvcNp7//93Vx/mqNLah9BOFGhhzTUfWLJs7uxZiKMdP/KzGOtzq14DrrqQ==
easy-stack@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/easy-stack/-/easy-stack-1.0.0.tgz#12c91b3085a37f0baa336e9486eac4bf94e3e788"
@ -12454,6 +12469,15 @@ vscode-uri@^1.0.6:
resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-1.0.8.tgz#9769aaececae4026fb6e22359cb38946580ded59"
integrity sha512-obtSWTlbJ+a+TFRYGaUumtVwb+InIUVI0Lu0VBUAPmj2cU5JutEXg3xUE0c2J5Tcy7h2DEKVJBFi+Y9ZSFzzPQ==
vue-advanced-cropper@^0.16.10:
version "0.16.10"
resolved "https://registry.yarnpkg.com/vue-advanced-cropper/-/vue-advanced-cropper-0.16.10.tgz#2792003c3cf55fb028c6822aa6f50f18019f2741"
integrity sha512-xUr7tTpbm+EyrILgLinhxj3NrmJlhUgMCtsifEldwZ8vHyHCCKCnvjRsMpiAQWPLrfhjMwECA0U77Mu93tHRow==
dependencies:
classnames "^2.2.6"
debounce "^1.2.0"
easy-bem "^1.0.2"
vue-cli-plugin-apollo@^0.21.3:
version "0.21.3"
resolved "https://registry.yarnpkg.com/vue-cli-plugin-apollo/-/vue-cli-plugin-apollo-0.21.3.tgz#520d336db0e88b26fe854833a555e2e29fe26571"