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: https://kolaente.dev/vikunja/frontend/pulls/200
This commit is contained in:
parent
617bcea04e
commit
ec1b039daa
11 changed files with 295 additions and 58 deletions
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
|
|
149
src/components/user/avatar-settings.vue
Normal file
149
src/components/user/avatar-settings.vue
Normal 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
9
src/models/avatar.js
Normal file
|
@ -0,0 +1,9 @@
|
|||
import AbstractModel from './abstractModel'
|
||||
|
||||
export default class AvatarModel extends AbstractModel {
|
||||
defaults() {
|
||||
return {
|
||||
avatarProvider: '',
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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
29
src/services/avatar.js
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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()
|
||||
|
|
24
yarn.lock
24
yarn.lock
|
@ -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"
|
||||
|
|
Loading…
Reference in a new issue