Add possibility to upload avatars

This commit is contained in:
Christian Cueni 2019-04-24 14:23:54 +02:00
parent b0d52e55b4
commit aca8bd0d2d
19 changed files with 311 additions and 49 deletions

View File

@ -1,7 +1,9 @@
<template>
<router-link to="/me/activity">
<div class="user-widget" :class="{'user-widget--is-profile': isProfile}">
<user-icon class="user-widget__avatar" :src="avatar"></user-icon>
<div class="user-widget__avatar">
<avatar :avatarUrl="avatarUrl" :iconHighlighted="isProfile" />
</div>
<span class="user-widget__name">{{firstName}} {{lastName}}</span>
<span class="user-widget__date" v-if="date">{{date}}</span>
</div>
@ -9,13 +11,13 @@
</template>
<script>
import UserIcon from '@/components/icons/UserIcon';
import Avatar from '@/components/profile/Avatar';
export default {
props: ['firstName', 'lastName', 'avatar', 'date'],
props: ['firstName', 'lastName', 'avatarUrl', 'date'],
components: {
UserIcon
Avatar
},
computed: {
isProfile() {
@ -46,15 +48,10 @@
&__avatar {
width: 30px;
height: 30px;
border-radius: 15px;
fill: $color-grey;
}
&--is-profile {
& > svg {
fill: $color-brand;
}
& > span {
color: $color-brand;
}

View File

@ -1,6 +1,6 @@
<template>
<div class="owner-widget">
<user-icon></user-icon>
<avatar class="owner-widget__avatar" :avatarUrl="avatarUrl" />
<span>
{{name}}
</span>
@ -8,32 +8,34 @@
</template>
<script>
import UserIcon from '@/components/icons/UserIcon.vue';
import Avatar from '@/components/profile/Avatar';
export default {
props: ['name'],
props: ['name', 'avatarUrl'],
components: {
UserIcon
Avatar
}
}
</script>
<style scoped lang="scss">
@import "@/styles/_mixins.scss";
@import "@/styles/_variables.scss";
.owner-widget {
display: flex;
align-items: center;
opacity: 0.6;
margin-top: $small-spacing;
svg {
&__avatar {
width: 30px;
fill: $color-darkgrey-1;
margin-right: 15px;
height: 30px;
}
& > span {
opacity: 0.6;
margin-left: $small-spacing;
@include room-widget-text-style;;
}
}

View File

@ -4,7 +4,7 @@
<h3 class="project-widget__title">{{title}}</h3>
<entry-count-widget :entry-count="entriesCount"></entry-count-widget>
<owner-widget :name="owner"></owner-widget>
<owner-widget :name="owner" :avatarUrl="student.avatarUrl"></owner-widget>
</router-link>
<widget-footer v-if="isOwner" class="project-widget__footer">
<template slot-scope="scope">

View File

@ -0,0 +1,84 @@
<template>
<div class="avatar">
<transition name="fade">
<user-icon v-show="!isAvatarLoaded" class="avatar__placeholder" :class="{'avatar__placeholder--highlighted': iconHighlighted}"></user-icon>
</transition>
<transition name="show">
<img v-show="isAvatarLoaded" class="avatar__image" :class="{'avatar__image--landscape': landscape}" :src="avatarUrl" ref="avatarImage" />
</transition>
</div>
</template>
<script>
import UserIcon from '@/components/icons/UserIcon';
export default {
props: ['avatarUrl', 'iconHighlighted'],
components: {UserIcon},
data () {
return {
isAvatarLoaded: false,
landscape: false
}
},
mounted () {
if (this.avatarUrl !== '') {
this.$refs.avatarImage.addEventListener('load', () => {
this.landscape = this.$refs.avatarImage.naturalHeight < this.$refs.avatarImage.naturalWidth;
this.isAvatarLoaded = true;
});
};
}
}
</script>
<style scoped lang="scss">
@import "@/styles/_variables.scss";
$max-width: 100%;
.avatar {
height: $max-width;
width: $max-width;
overflow: hidden;
text-align: center;
border-radius: 50%;
// clip-path: circle(40%);
position: relative;
&__placeholder {
height: $max-width;
fill: $color-grey;
&--highlighted {
fill: $color-brand;
}
}
&__image {
position: absolute;
width: $max-width;
height: auto;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
&--landscape {
width: auto;
height: $max-width;
}
}
.fade-leave-active, .show-enter-active {
transition: opacity .5s;
}
.fade-leave-to, .show-enter {
opacity: 0;
}
.show-enter-to {
opacity: 1;
}
}
</style>

View File

@ -0,0 +1,33 @@
<template>
<div class="avatar-upload">
<image-form :value="value" index="0" @link-change-url="changeLinkUrl" />
</div>
</template>
<script>
import ImageForm from '@/components/content-forms/ImageForm';
export default {
components: {
ImageForm
},
methods: {
changeLinkUrl(value, index) {
this.$emit('avatarUpdate', value);
}
},
data() {
return {
value: {
url: ''
}
}
},
}
</script>
<style scoped lang="scss">
@import "@/styles/_variables.scss";
</style>

View File

@ -1,10 +1,10 @@
<template>
<div class="password-reset">
<h1 class="password-reset__header">Passwort ändern</h1>
<h2 class="password-reset__header">Passwort ändern</h2>
<div v-if="showSuccess" class="success-message">
<p class="success-message__msg" data-cy="password-change-success">Dein Password wurde erfolgreich geändert.</p>
</div>
<password-change
<password-change-form
@passwordSubmited="resetPassword"
:oldPasswordErrors="oldPasswordErrors"
:newPasswordErrors="newPasswordErrors" />
@ -14,11 +14,11 @@
<script>
import UPDATE_PASSWORD_MUTATION from '@/graphql/gql/mutations/updatePassword.gql';
import PasswordChange from '@/components/PasswordChange'
import PasswordChangeForm from '@/components/profile/PasswordChangeForm'
export default {
components: {
PasswordChange
PasswordChangeForm
},
data() {

View File

@ -0,0 +1,95 @@
<template>
<div class="profile">
<h1 class="profile__header">Profil</h1>
<h2 class="profile__avatar profile-avatar">Profilbild</h2>
<div class="profile-avatar" v-if="me.avatarUrl" >
<div class="profile-avatar__image">
<avatar :avatarUrl="me.avatarUrl" />
</div>
<a class="profile-avatar__remove icon-button" @click="deleteAvatar()">
<trash-icon class="profile-avatar__remove-icon icon-button__icon"></trash-icon>
</a>
</div>
<avatar-upload-form v-else @avatarUpdate="updateAvatar"/>
<password-change />
</div>
</template>
<script>
import UPDATE_AVATAR_QUERY from '@/graphql/gql/mutations/UpdateAvatarUrl.gql';
import ME_QUERY from '@/graphql/gql/meQuery.gql';
import PasswordChange from '@/components/profile/PasswordChange';
import AvatarUploadForm from '@/components/profile/AvatarUploadForm';
import Avatar from '@/components/profile/Avatar';
import TrashIcon from '@/components/icons/TrashIcon';
export default {
components: {
PasswordChange,
AvatarUploadForm,
Avatar,
TrashIcon
},
data() {
return {
me: {
avatarUrl: ''
}
}
},
apollo: {
me: {
query: ME_QUERY,
},
},
methods: {
deleteAvatar () {
this.updateAvatar('');
},
updateAvatar (url) {
this.$apollo.mutate({
mutation: UPDATE_AVATAR_QUERY,
variables: {
input: {
avatarUrl: url
}
},
update(store, {data: {updateAvatar: {success}}}) {
if (success) {
const data = store.readQuery({query: ME_QUERY});
if (data) {
data.me.avatarUrl = url;
store.writeQuery({query: ME_QUERY, data});
}
}
}
}).then(({ data }) => {
// this.me.avatarUrl = url;
}).catch((error) => {
console.warn('UploadError', error)
});
}
}
}
</script>
<style scoped lang="scss">
@import "@/styles/_variables.scss";
.profile-avatar {
display: flex;
flex-direction: row;
&__image {
height: 230px;
width: 230px;
}
}
.profile-avatar {
margin-bottom: $large-spacing;
}
</style>

View File

@ -9,7 +9,8 @@ fragment ProjectParts on ProjectNode {
student {
firstName
lastName
id
id,
avatarUrl
}
entriesCount
}

View File

@ -7,5 +7,6 @@ fragment RoomEntryParts on RoomEntryNode {
id
firstName
lastName
avatarUrl
}
}

View File

@ -6,7 +6,7 @@ fragment UserParts on UserNode {
email
firstName
lastName
avatar
avatarUrl
lastModule {
id
slug

View File

@ -0,0 +1,11 @@
mutation UpdateAvatar($input: UpdateAvatarInput!) {
updateAvatar(input: $input) {
success
errors {
field
errors {
code
}
}
}
}

View File

@ -7,28 +7,14 @@
<router-link to="/me/myclasses" active-class="top-navigation__link--active"
class="top-navigation__link profile-submenu__item submenu-item">Klassenliste
</router-link>
<router-link to="/me/password-change" active-class="top-navigation__link--active"
class="top-navigation__link profile-submenu__item submenu-item">Passwort ändern
<router-link to="/me/profile" active-class="top-navigation__link--active"
class="top-navigation__link profile-submenu__item submenu-item">Profil
</router-link>
</nav>
<router-view></router-view>
</div>
</template>
<script>
export default {
components: {
},
data() {
return {
me: []
}
}
}
</script>
<style scoped lang="scss">
@import "@/styles/_variables.scss";
@import "@/styles/_functions.scss";

View File

@ -18,7 +18,7 @@ import submission from '@/pages/studentSubmission'
import portfolio from '@/pages/portfolio'
import project from '@/pages/project'
import profilePage from '@/pages/profile'
import passwordChange from '@/pages/passwordChange'
import profile from '@/components/profile/profile'
import myClasses from '@/pages/myClasses'
import activity from '@/pages/activity'
import Router from 'vue-router'
@ -81,7 +81,7 @@ const routes = [
path: '/me',
component: profilePage,
children: [
{path: 'password-change', name: 'pw-change', component: passwordChange, meta: {isProfile: true}},
{path: 'profile', name: 'profile', component: profile, meta: {isProfile: true}},
{path: 'myclasses', name: 'my-classes', component: myClasses, meta: {isProfile: true}},
{path: 'activity', name: 'activity', component: activity, meta: {isProfile: true}},
{path: '', name: 'profile-activity', component: activity, meta: {isProfile: true}},

View File

@ -0,0 +1,18 @@
# Generated by Django 2.0.6 on 2019-04-23 12:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0003_user_last_module'),
]
operations = [
migrations.AddField(
model_name='user',
name='avatar_url',
field=models.CharField(blank=True, default='', max_length=254),
),
]

View File

@ -12,6 +12,7 @@ DEFAULT_SCHOOL_ID = 1
class User(AbstractUser):
last_module = models.ForeignKey('books.Module', related_name='+', on_delete=models.SET_NULL, null=True)
avatar_url = models.CharField(max_length=254, blank=True, default='')
def get_role_permissions(self):
perms = set()

View File

@ -2,7 +2,7 @@ import graphene
from django.contrib.auth import update_session_auth_hash
from graphene import relay
from users.inputs import PasswordUpdateInput
from users.serializers import PasswordSerialzer
from users.serializers import PasswordSerialzer, AvatarUrlSerializer
class FieldError(graphene.ObjectType):
@ -45,5 +45,38 @@ class UpdatePassword(relay.ClientIDMutation):
return cls(success=False, errors=errors)
class UpdateAvatar(relay.ClientIDMutation):
class Input:
avatar_url = graphene.String()
success = graphene.Boolean()
errors = graphene.List(UpdateError)
@classmethod
def mutate_and_get_payload(cls, root, info, **kwargs):
user = info.context.user
avatar_data = kwargs.get('avatar_url')
serializer = AvatarUrlSerializer(data={'avatar_url': avatar_data})
if serializer.is_valid():
user.avatar_url = avatar_data
user.save()
return cls(success=True)
errors = []
for key, value in serializer.errors.items():
error = UpdateError(field=key, errors=[])
for field_error in serializer.errors[key]:
error.errors.append(FieldError(code=field_error.code))
errors.append(error)
return cls(success=False, errors=errors)
class ProfileMutations:
update_password = UpdatePassword.Field()
update_avatar = UpdateAvatar.Field()

File diff suppressed because one or more lines are too long

View File

@ -11,7 +11,7 @@ import re
from django.contrib.auth import get_user_model
from rest_framework import serializers
from rest_framework.fields import CharField
from rest_framework.fields import CharField, URLField
from django.utils.translation import ugettext_lazy as _
MIN_PASSWORD_LENGTH = 8
@ -63,3 +63,7 @@ class PasswordSerialzer(serializers.Serializer):
def validate(self, obj):
return validate_old_new_password(obj)
class AvatarUrlSerializer(serializers.Serializer):
avatar_url = URLField(allow_blank=True)