Merged in feature/profile-image (pull request #16)
Feature/profile image Approved-by: Ramon Wenger <ramon.wenger@iterativ.ch>
This commit is contained in:
commit
ed38e73f5b
|
|
@ -1,19 +1,21 @@
|
|||
<template>
|
||||
<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 :avatar-url="avatarUrl" :icon-highlighted="isProfile" />
|
||||
</div>
|
||||
<span class="user-widget__name">{{firstName}} {{lastName}}</span>
|
||||
<span class="user-widget__date" v-if="date">{{date}}</span>
|
||||
</div>
|
||||
</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() {
|
||||
|
|
@ -44,15 +46,10 @@
|
|||
&__avatar {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 15px;
|
||||
fill: $color-grey;
|
||||
}
|
||||
|
||||
&--is-profile {
|
||||
& > svg {
|
||||
fill: $color-brand;
|
||||
}
|
||||
|
||||
& > span {
|
||||
color: $color-brand;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,87 @@
|
|||
<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">
|
||||
<div v-show="isAvatarLoaded" class="avatar__image" ref="avatarImage" :style="{'background-image': `url(${this.avatarUrl})`}"></div>
|
||||
</transition>
|
||||
<img class="avatar__fake-image" :src="avatarUrl" ref="fakeImage"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import UserIcon from '@/components/icons/UserIcon';
|
||||
|
||||
export default {
|
||||
props: ['avatarUrl', 'iconHighlighted'],
|
||||
components: {UserIcon},
|
||||
data () {
|
||||
return {
|
||||
isAvatarLoaded: false
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
if (this.avatarUrl !== '') {
|
||||
this.$refs.fakeImage.addEventListener('load', () => {
|
||||
this.$refs.fakeImage.remove();
|
||||
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%;
|
||||
|
||||
&__placeholder {
|
||||
height: $max-width;
|
||||
fill: $color-grey;
|
||||
|
||||
&--highlighted {
|
||||
fill: $color-brand;
|
||||
}
|
||||
}
|
||||
|
||||
&__image {
|
||||
|
||||
background-size: cover;
|
||||
background-position: center center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
|
||||
&--landscape {
|
||||
width: auto;
|
||||
height: $max-width;
|
||||
}
|
||||
}
|
||||
|
||||
&__fake-image {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.fade-leave-active, .show-enter-active {
|
||||
transition: opacity .5s;
|
||||
}
|
||||
.fade-leave-to, .show-enter {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.show-enter-to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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() {
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
<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});
|
||||
}
|
||||
}
|
||||
}
|
||||
}).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>
|
||||
|
|
@ -9,7 +9,8 @@ fragment ProjectParts on ProjectNode {
|
|||
student {
|
||||
firstName
|
||||
lastName
|
||||
id
|
||||
id,
|
||||
avatarUrl
|
||||
}
|
||||
entriesCount
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,5 +7,6 @@ fragment RoomEntryParts on RoomEntryNode {
|
|||
id
|
||||
firstName
|
||||
lastName
|
||||
avatarUrl
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ fragment UserParts on UserNode {
|
|||
email
|
||||
firstName
|
||||
lastName
|
||||
avatar
|
||||
avatarUrl
|
||||
lastModule {
|
||||
id
|
||||
slug
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
mutation UpdateAvatar($input: UpdateAvatarInput!) {
|
||||
updateAvatar(input: $input) {
|
||||
success
|
||||
errors {
|
||||
field
|
||||
errors {
|
||||
code
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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}},
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
@ -19,7 +19,7 @@ class UpdatePassword(relay.ClientIDMutation):
|
|||
password_input = graphene.Argument(PasswordUpdateInput)
|
||||
|
||||
success = graphene.Boolean()
|
||||
errors = graphene.List(UpdateError)
|
||||
errors = graphene.List(UpdateError) # todo: change for consistency
|
||||
|
||||
@classmethod
|
||||
def mutate_and_get_payload(cls, root, info, **kwargs):
|
||||
|
|
@ -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) # todo: change for consistency
|
||||
|
||||
@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
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue