API & Client - move user preferences + add picture edition

This commit is contained in:
Sam 2021-10-17 21:01:14 +02:00
parent b70dd3116e
commit c92446ff39
25 changed files with 1291 additions and 478 deletions

View File

@ -529,9 +529,6 @@ class TestUserProfileUpdate(ApiTestCaseMixin):
birth_date='1980-01-01', birth_date='1980-01-01',
password='87654321', password='87654321',
password_conf='87654321', password_conf='87654321',
timezone='America/New_York',
weekm=True,
language='fr',
) )
), ),
headers=dict(Authorization=f'Bearer {auth_token}'), headers=dict(Authorization=f'Bearer {auth_token}'),
@ -550,9 +547,9 @@ class TestUserProfileUpdate(ApiTestCaseMixin):
assert data['data']['birth_date'] assert data['data']['birth_date']
assert data['data']['bio'] == 'Nothing to tell' assert data['data']['bio'] == 'Nothing to tell'
assert data['data']['location'] == 'Somewhere' assert data['data']['location'] == 'Somewhere'
assert data['data']['timezone'] == 'America/New_York' assert data['data']['timezone'] is None
assert data['data']['weekm'] is True assert data['data']['weekm'] is False
assert data['data']['language'] == 'fr' assert data['data']['language'] is None
assert data['data']['nb_sports'] == 0 assert data['data']['nb_sports'] == 0
assert data['data']['nb_workouts'] == 0 assert data['data']['nb_workouts'] == 0
assert data['data']['records'] == [] assert data['data']['records'] == []
@ -575,9 +572,6 @@ class TestUserProfileUpdate(ApiTestCaseMixin):
location='Somewhere', location='Somewhere',
bio='Nothing to tell', bio='Nothing to tell',
birth_date='1980-01-01', birth_date='1980-01-01',
timezone='America/New_York',
weekm=True,
language='fr',
) )
), ),
headers=dict(Authorization=f'Bearer {auth_token}'), headers=dict(Authorization=f'Bearer {auth_token}'),
@ -596,9 +590,9 @@ class TestUserProfileUpdate(ApiTestCaseMixin):
assert data['data']['birth_date'] assert data['data']['birth_date']
assert data['data']['bio'] == 'Nothing to tell' assert data['data']['bio'] == 'Nothing to tell'
assert data['data']['location'] == 'Somewhere' assert data['data']['location'] == 'Somewhere'
assert data['data']['timezone'] == 'America/New_York' assert data['data']['timezone'] is None
assert data['data']['weekm'] is True assert data['data']['weekm'] is False
assert data['data']['language'] == 'fr' assert data['data']['language'] is None
assert data['data']['nb_sports'] == 0 assert data['data']['nb_sports'] == 0
assert data['data']['nb_workouts'] == 0 assert data['data']['nb_workouts'] == 0
assert data['data']['records'] == [] assert data['data']['records'] == []
@ -657,9 +651,6 @@ class TestUserProfileUpdate(ApiTestCaseMixin):
birth_date='1980-01-01', birth_date='1980-01-01',
password='87654321', password='87654321',
password_conf='876543210', password_conf='876543210',
timezone='America/New_York',
weekm=True,
language='en',
) )
), ),
headers=dict(Authorization=f'Bearer {auth_token}'), headers=dict(Authorization=f'Bearer {auth_token}'),
@ -689,9 +680,6 @@ class TestUserProfileUpdate(ApiTestCaseMixin):
bio='just a random guy', bio='just a random guy',
birth_date='1980-01-01', birth_date='1980-01-01',
password='87654321', password='87654321',
timezone='America/New_York',
weekm=True,
language='en',
) )
), ),
headers=dict(Authorization=f'Bearer {auth_token}'), headers=dict(Authorization=f'Bearer {auth_token}'),
@ -706,6 +694,83 @@ class TestUserProfileUpdate(ApiTestCaseMixin):
assert response.status_code == 400 assert response.status_code == 400
class TestUserPreferencesUpdate(ApiTestCaseMixin):
def test_it_updates_user_preferences(
self, app: Flask, user_1: User
) -> None:
client, auth_token = self.get_test_client_and_auth_token(app)
response = client.post(
'/api/auth/profile/edit/preferences',
content_type='application/json',
data=json.dumps(
dict(
timezone='America/New_York',
weekm=True,
language='fr',
)
),
headers=dict(Authorization=f'Bearer {auth_token}'),
)
data = json.loads(response.data.decode())
assert data['status'] == 'success'
assert data['message'] == 'User preferences updated.'
assert response.status_code == 200
assert data['data']['username'] == 'test'
assert data['data']['email'] == 'test@test.com'
assert not data['data']['admin']
assert data['data']['created_at']
assert data['data']['first_name'] is None
assert data['data']['last_name'] is None
assert data['data']['birth_date'] is None
assert data['data']['bio'] is None
assert data['data']['location'] is None
assert data['data']['timezone'] == 'America/New_York'
assert data['data']['weekm'] is True
assert data['data']['language'] == 'fr'
assert data['data']['nb_sports'] == 0
assert data['data']['nb_workouts'] == 0
assert data['data']['records'] == []
assert data['data']['sports_list'] == []
assert data['data']['total_distance'] == 0
assert data['data']['total_duration'] == '0:00:00'
def test_it_returns_error_if_fields_are_missing(
self, app: Flask, user_1: User
) -> None:
client, auth_token = self.get_test_client_and_auth_token(app)
response = client.post(
'/api/auth/profile/edit/preferences',
content_type='application/json',
data=json.dumps(dict(weekm=True)),
headers=dict(Authorization=f'Bearer {auth_token}'),
)
data = json.loads(response.data.decode())
assert data['status'] == 'error'
assert data['message'] == 'Invalid payload.'
assert response.status_code == 400
def test_it_returns_error_if_payload_is_empty(
self, app: Flask, user_1: User
) -> None:
client, auth_token = self.get_test_client_and_auth_token(app)
response = client.post(
'/api/auth/profile/edit/preferences',
content_type='application/json',
data=json.dumps(dict()),
headers=dict(Authorization=f'Bearer {auth_token}'),
)
data = json.loads(response.data.decode())
assert response.status_code == 400
assert 'Invalid payload.' in data['message']
assert 'error' in data['status']
class TestUserPicture(ApiTestCaseMixin): class TestUserPicture(ApiTestCaseMixin):
def test_it_updates_user_picture(self, app: Flask, user_1: User) -> None: def test_it_updates_user_picture(self, app: Flask, user_1: User) -> None:
client, auth_token = self.get_test_client_and_auth_token(app) client, auth_token = self.get_test_client_and_auth_token(app)

View File

@ -468,9 +468,6 @@ def edit_user(auth_user_id: int) -> Union[Dict, HttpResponse]:
:<json string birth_date: user birth date (format: ``%Y-%m-%d``) :<json string birth_date: user birth date (format: ``%Y-%m-%d``)
:<json string password: user password :<json string password: user password
:<json string password_conf: user password confirmation :<json string password_conf: user password confirmation
:<json string timezone: user time zone
:<json string weekm: does week start on Monday?
:<json string language: language preferences
:reqheader Authorization: OAuth 2.0 Bearer Token :reqheader Authorization: OAuth 2.0 Bearer Token
@ -492,10 +489,7 @@ def edit_user(auth_user_id: int) -> Union[Dict, HttpResponse]:
'last_name', 'last_name',
'bio', 'bio',
'birth_date', 'birth_date',
'language',
'location', 'location',
'timezone',
'weekm',
} }
if not post_data or not post_data.keys() >= user_mandatory_data: if not post_data or not post_data.keys() >= user_mandatory_data:
return InvalidPayloadErrorResponse() return InvalidPayloadErrorResponse()
@ -504,12 +498,9 @@ def edit_user(auth_user_id: int) -> Union[Dict, HttpResponse]:
last_name = post_data.get('last_name') last_name = post_data.get('last_name')
bio = post_data.get('bio') bio = post_data.get('bio')
birth_date = post_data.get('birth_date') birth_date = post_data.get('birth_date')
language = post_data.get('language')
location = post_data.get('location') location = post_data.get('location')
password = post_data.get('password') password = post_data.get('password')
password_conf = post_data.get('password_conf') password_conf = post_data.get('password_conf')
timezone = post_data.get('timezone')
weekm = post_data.get('weekm')
if password is not None and password != '': if password is not None and password != '':
message = check_passwords(password, password_conf) message = check_passwords(password, password_conf)
@ -524,7 +515,6 @@ def edit_user(auth_user_id: int) -> Union[Dict, HttpResponse]:
user.first_name = first_name user.first_name = first_name
user.last_name = last_name user.last_name = last_name
user.bio = bio user.bio = bio
user.language = language
user.location = location user.location = location
user.birth_date = ( user.birth_date = (
datetime.datetime.strptime(birth_date, '%Y-%m-%d') datetime.datetime.strptime(birth_date, '%Y-%m-%d')
@ -533,13 +523,147 @@ def edit_user(auth_user_id: int) -> Union[Dict, HttpResponse]:
) )
if password is not None and password != '': if password is not None and password != '':
user.password = password user.password = password
db.session.commit()
return {
'status': 'success',
'message': 'User profile updated.',
'data': user.serialize(),
}
# handler errors
except (exc.IntegrityError, exc.OperationalError, ValueError) as e:
return handle_error_and_return_response(e, db=db)
@auth_blueprint.route('/auth/profile/edit/preferences', methods=['POST'])
@authenticate
def edit_user_preferences(auth_user_id: int) -> Union[Dict, HttpResponse]:
"""
edit authenticated user preferences
**Example request**:
.. sourcecode:: http
POST /api/auth/profile/edit/preferences HTTP/1.1
Content-Type: application/json
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/json
{
"data": {
"admin": false,
"bio": null,
"birth_date": null,
"created_at": "Sun, 14 Jul 2019 14:09:58 GMT",
"email": "sam@example.com",
"first_name": null,
"language": "en",
"last_name": null,
"location": null,
"nb_sports": 3,
"nb_workouts": 6,
"picture": false,
"records": [
{
"id": 9,
"record_type": "AS",
"sport_id": 1,
"user": "sam",
"value": 18,
"workout_date": "Sun, 07 Jul 2019 08:00:00 GMT",
"workout_id": "hvYBqYBRa7wwXpaStWR4V2"
},
{
"id": 10,
"record_type": "FD",
"sport_id": 1,
"user": "sam",
"value": 18,
"workout_date": "Sun, 07 Jul 2019 08:00:00 GMT",
"workout_id": "hvYBqYBRa7wwXpaStWR4V2"
},
{
"id": 11,
"record_type": "LD",
"sport_id": 1,
"user": "sam",
"value": "1:01:00",
"workout_date": "Sun, 07 Jul 2019 08:00:00 GMT",
"workout_id": "hvYBqYBRa7wwXpaStWR4V2"
},
{
"id": 12,
"record_type": "MS",
"sport_id": 1,
"user": "sam",
"value": 18,
"workout_date": "Sun, 07 Jul 2019 08:00:00 GMT",
"workout_id": "hvYBqYBRa7wwXpaStWR4V2"
}
],
"sports_list": [
1,
4,
6
],
"timezone": "Europe/Paris",
"total_distance": 67.895,
"total_duration": "6:50:27",
"username": "sam"
"weekm": true,
},
"message": "User preferences updated.",
"status": "success"
}
:<json string timezone: user time zone
:<json string weekm: does week start on Monday?
:<json string language: language preferences
:reqheader Authorization: OAuth 2.0 Bearer Token
:statuscode 200: User profile updated.
:statuscode 400:
- Invalid payload.
- Password and password confirmation don't match.
:statuscode 401:
- Provide a valid auth token.
- Signature expired. Please log in again.
- Invalid token. Please log in again.
:statuscode 500: Error. Please try again or contact the administrator.
"""
# get post data
post_data = request.get_json()
user_mandatory_data = {
'language',
'timezone',
'weekm',
}
if not post_data or not post_data.keys() >= user_mandatory_data:
return InvalidPayloadErrorResponse()
language = post_data.get('language')
timezone = post_data.get('timezone')
weekm = post_data.get('weekm')
try:
user = User.query.filter_by(id=auth_user_id).first()
user.language = language
user.timezone = timezone user.timezone = timezone
user.weekm = weekm user.weekm = weekm
db.session.commit() db.session.commit()
return { return {
'status': 'success', 'status': 'success',
'message': 'User profile updated.', 'message': 'User preferences updated.',
'data': user.serialize(), 'data': user.serialize(),
} }

View File

@ -41,15 +41,7 @@
<div class="nav-items-user-menu"> <div class="nav-items-user-menu">
<div class="nav-items-group" v-if="isAuthenticated"> <div class="nav-items-group" v-if="isAuthenticated">
<div class="nav-item nav-profile-img"> <div class="nav-item nav-profile-img">
<img <UserPicture :user="authUser" />
v-if="authUserPictureUrl !== ''"
class="nav-profile-user-img"
:alt="t('user.USER_PICTURE')"
:src="authUserPictureUrl"
/>
<div v-else class="no-picture">
<i class="fa fa-user-circle-o" aria-hidden="true" />
</div>
</div> </div>
<router-link class="nav-item" to="/profile" @click="closeMenu"> <router-link class="nav-item" to="/profile" @click="closeMenu">
{{ authUser.username }} {{ authUser.username }}
@ -86,6 +78,7 @@
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import Dropdown from '@/components/Common/Dropdown.vue' import Dropdown from '@/components/Common/Dropdown.vue'
import UserPicture from '@/components/User/UserPicture.vue'
import { ROOT_STORE, USER_STORE } from '@/store/constants' import { ROOT_STORE, USER_STORE } from '@/store/constants'
import { IDropdownOption } from '@/types/forms' import { IDropdownOption } from '@/types/forms'
import { IAuthUserProfile } from '@/types/user' import { IAuthUserProfile } from '@/types/user'
@ -96,6 +89,7 @@
name: 'NavBar', name: 'NavBar',
components: { components: {
Dropdown, Dropdown,
UserPicture,
}, },
emits: ['menuInteraction'], emits: ['menuInteraction'],
setup(props, { emit }) { setup(props, { emit }) {
@ -249,17 +243,17 @@
.nav-profile-img { .nav-profile-img {
margin-bottom: -$default-padding; margin-bottom: -$default-padding;
.nav-profile-user-img { ::v-deep(.user-picture) {
border-radius: 50%; img {
height: 32px; height: 32px;
width: 32px; width: 32px;
object-fit: cover; object-fit: cover;
} }
.no-picture { .no-picture {
color: var(--app-a-color);
font-size: 1.7em; font-size: 1.7em;
} }
} }
}
.nav-separator { .nav-separator {
display: none; display: none;

View File

@ -7,9 +7,13 @@
@click="emit('arrowClick', true)" @click="emit('arrowClick', true)"
/> />
</div> </div>
<div class="time-frames"> <div class="time-frames custom-checkboxes-group">
<div class="time-frames-checkboxes"> <div class="time-frames-checkboxes custom-checkboxes">
<div v-for="frame in timeFrames" class="time-frame" :key="frame"> <div
v-for="frame in timeFrames"
class="time-frame custom-checkbox"
:key="frame"
>
<label> <label>
<input <input
type="radio" type="radio"
@ -76,41 +80,5 @@
.chart-arrow { .chart-arrow {
cursor: pointer; cursor: pointer;
} }
.time-frames {
display: flex;
justify-content: space-around;
.time-frames-checkboxes {
display: inline-flex;
.time-frame {
label {
font-weight: normal;
float: left;
padding: 0 5px;
cursor: pointer;
}
label input {
display: none;
}
label span {
border: solid 1px var(--time-frame-border-color);
border-radius: 9%;
display: block;
font-size: 0.9em;
padding: 2px 6px;
text-align: center;
}
input:checked + span {
background-color: var(--time-frame-checked-bg-color);
color: var(--time-frame-checked-color);
}
}
}
}
} }
</style> </style>

View File

@ -1,5 +1,5 @@
<template> <template>
<div id="user-infos"> <div id="user-infos" class="description-list">
<dl> <dl>
<dt>{{ t('user.PROFILE.REGISTRATION_DATE') }}:</dt> <dt>{{ t('user.PROFILE.REGISTRATION_DATE') }}:</dt>
<dd>{{ registrationDate }}</dd> <dd>{{ registrationDate }}</dd>
@ -43,8 +43,7 @@
import { IAuthUserProfile } from '@/types/user' import { IAuthUserProfile } from '@/types/user'
export default defineComponent({ export default defineComponent({
name: 'Profile', name: 'UserInfos',
components: {},
props: { props: {
user: { user: {
type: Object as PropType<IAuthUserProfile>, type: Object as PropType<IAuthUserProfile>,
@ -71,33 +70,6 @@
<style lang="scss" scoped> <style lang="scss" scoped>
@import '~@/scss/base.scss'; @import '~@/scss/base.scss';
#user-infos { #user-infos {
dl {
overflow: hidden;
width: 100%;
padding: 0 $default-padding;
dt {
font-weight: bold;
float: left;
width: 25%;
}
dd {
float: left;
}
}
@media screen and (max-width: $x-small-limit) {
dl {
overflow: auto;
width: initial;
dt {
font-weight: bold;
float: none;
width: initial;
}
dd {
float: none;
}
}
}
.user-bio { .user-bio {
white-space: pre-wrap; white-space: pre-wrap;
} }

View File

@ -0,0 +1,62 @@
<template>
<div id="user-preferences" class="description-list">
<dl>
<dt>{{ t('user.PROFILE.LANGUAGE') }}:</dt>
<dd>{{ language }}</dd>
</dl>
<dl>
<dt>{{ t('user.PROFILE.TIMEZONE') }}:</dt>
<dd>{{ timezone }}</dd>
</dl>
<dl>
<dt>{{ t('user.PROFILE.FIRST_DAY_OF_WEEK') }}:</dt>
<dd>{{ t(`user.PROFILE.${fistDayOfWeek}`) }}</dd>
</dl>
<div class="profile-buttons">
<button @click="$router.push('/profile/edit/preferences')">
{{ t('user.PROFILE.EDIT_PREFERENCES') }}
</button>
<button @click="$router.push('/')">{{ t('common.HOME') }}</button>
</div>
</div>
</template>
<script lang="ts">
import { PropType, computed, defineComponent } from 'vue'
import { useI18n } from 'vue-i18n'
import { IAuthUserProfile } from '@/types/user'
export default defineComponent({
name: 'UserPreferences',
props: {
user: {
type: Object as PropType<IAuthUserProfile>,
required: true,
},
},
setup(props) {
const { t } = useI18n()
const language = computed(() =>
props.user.language ? props.user.language.toUpperCase() : 'EN'
)
const fistDayOfWeek = computed(() =>
props.user.weekm ? 'MONDAY' : 'SUNDAY'
)
const timezone = computed(() =>
props.user.timezone ? props.user.timezone : 'Europe/Paris'
)
return { fistDayOfWeek, language, t, timezone }
},
})
</script>
<style lang="scss" scoped>
@import '~@/scss/base.scss';
#user-preferences {
.profile-buttons {
display: flex;
gap: $default-padding;
}
}
</style>

View File

@ -1,17 +1,7 @@
<template> <template>
<div id="user-profile"> <div id="user-profile">
<div class="box user-header"> <div class="box user-header">
<div class="user-picture"> <UserPicture :user="user" />
<img
v-if="authUserPictureUrl !== ''"
class="nav-profile-user-img"
:alt="t('user.USER_PICTURE')"
:src="authUserPictureUrl"
/>
<div v-else class="no-picture">
<i class="fa fa-user-circle-o" aria-hidden="true" />
</div>
</div>
<div class="user-details"> <div class="user-details">
<div class="user-name">{{ user.username }}</div> <div class="user-name">{{ user.username }}</div>
<div class="user-stats"> <div class="user-stats">
@ -37,7 +27,9 @@
</div> </div>
</div> </div>
<div class="box"> <div class="box">
<UserInfos :user="user" /> <UserProfileTabs :tabs="tabs" :selectedTab="tab" :edition="false" />
<UserInfos :user="user" v-if="tab === 'PROFILE'" />
<UserPreferences :user="user" v-if="tab === 'PREFERENCES'" />
</div> </div>
</div> </div>
</template> </template>
@ -47,28 +39,39 @@
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import UserInfos from '@/components/User/ProfileDisplay/UserInfos.vue' import UserInfos from '@/components/User/ProfileDisplay/UserInfos.vue'
import UserPreferences from '@/components/User/ProfileDisplay/UserPreferences.vue'
import UserPicture from '@/components/User/UserPicture.vue'
import UserProfileTabs from '@/components/User/UserProfileTabs.vue'
import { IAuthUserProfile } from '@/types/user' import { IAuthUserProfile } from '@/types/user'
import { getApiUrl } from '@/utils' import { getApiUrl } from '@/utils'
export default defineComponent({ export default defineComponent({
name: 'Profile', name: 'ProfileDisplay',
components: { components: {
UserInfos, UserInfos,
UserPicture,
UserPreferences,
UserProfileTabs,
}, },
props: { props: {
user: { user: {
type: Object as PropType<IAuthUserProfile>, type: Object as PropType<IAuthUserProfile>,
required: true, required: true,
}, },
tab: {
type: String,
required: true,
},
}, },
setup(props) { setup(props) {
const { t } = useI18n() const { t } = useI18n()
const tabs = ['PROFILE', 'PREFERENCES']
const authUserPictureUrl: ComputedRef<string> = computed(() => const authUserPictureUrl: ComputedRef<string> = computed(() =>
props.user.picture props.user.picture
? `${getApiUrl()}/users/${props.user.username}/picture?${Date.now()}` ? `${getApiUrl()}/users/${props.user.username}/picture?${Date.now()}`
: '' : ''
) )
return { authUserPictureUrl, t } return { authUserPictureUrl, t, tabs }
}, },
}) })
</script> </script>
@ -88,22 +91,6 @@
display: flex; display: flex;
align-items: stretch; align-items: stretch;
.user-picture {
display: flex;
justify-content: center;
align-items: center;
min-width: 30%;
img {
border-radius: 50%;
height: 90px;
width: 90px;
}
.no-picture {
color: var(--app-a-color);
font-size: 5.5em;
}
}
.user-details { .user-details {
flex-grow: 1; flex-grow: 1;
padding: $default-padding; padding: $default-padding;

View File

@ -1,318 +0,0 @@
<template>
<div id="user-profile-edition">
<Modal
v-if="displayModal"
:title="t('common.CONFIRMATION')"
:message="t('user.CONFIRM_ACCOUNT_DELETION')"
@confirmAction="deleteAccount(user.username)"
@cancelAction="updateDisplayModal(false)"
/>
<Card>
<template #title>{{ t('user.PROFILE.EDITION') }}</template>
<template #content>
<div class="profile-form form-box">
<ErrorMessage :message="errorMessages" v-if="errorMessages" />
<form @submit.prevent="updateProfile">
<label class="form-items" for="email">
{{ t('user.EMAIL') }}
<input id="email" :value="user.email" disabled />
</label>
<label class="form-items" for="registrationDate">
{{ t('user.PROFILE.REGISTRATION_DATE') }}
<input id="registrationDate" :value="registrationDate" disabled />
</label>
<label class="form-items" for="password">
{{ t('user.PASSWORD') }}
<input
id="password"
type="password"
v-model="userForm.password"
:disabled="loading"
/>
</label>
<label class="form-items" for="passwordConfirmation">
{{ t('user.PASSWORD_CONFIRMATION') }}
<input
id="passwordConfirmation"
type="password"
v-model="userForm.password_conf"
:disabled="loading"
/>
</label>
<hr />
<label class="form-items" for="first_name">
{{ t('user.PROFILE.FIRST_NAME') }}
<input
id="first_name"
v-model="userForm.first_name"
:disabled="loading"
/>
</label>
<label class="form-items" for="last_name">
{{ t('user.PROFILE.LAST_NAME') }}
<input id="last_name" v-model="userForm.last_name" />
</label>
<label class="form-items" for="birth_date">
{{ t('user.PROFILE.BIRTH_DATE') }}
<input
id="birth_date"
type="date"
class="birth-date"
v-model="userForm.birth_date"
:disabled="loading"
/>
</label>
<label class="form-items" for="location">
{{ t('user.PROFILE.LOCATION') }}
<input
id="location"
v-model="userForm.location"
:disabled="loading"
/>
</label>
<label class="form-items">
{{ t('user.PROFILE.BIO') }}
<CustomTextArea
name="bio"
:charLimit="200"
:input="userForm.bio"
:disabled="loading"
@updateValue="updateBio"
/>
</label>
<label class="form-items">
{{ t('user.PROFILE.LANGUAGE') }}
<select
id="language"
v-model="userForm.language"
:disabled="loading"
>
<option
v-for="lang in availableLanguages"
:value="lang.value"
:key="lang.value"
>
{{ lang.label }}
</option>
</select>
</label>
<label class="form-items" for="timezone">
{{ t('user.PROFILE.TIMEZONE') }}
<input
id="timezone"
v-model="userForm.timezone"
:disabled="loading"
/>
</label>
<label class="form-items">
{{ t('user.PROFILE.FIRST_DAY_OF_WEEK') }}
<select id="weekm" v-model="userForm.weekm" :disabled="loading">
<option
v-for="start in weekStart"
:value="start.value"
:key="start.value"
>
{{ t(`user.PROFILE.${start.label}`) }}
</option>
</select>
</label>
<div class="form-buttons">
<button class="confirm" type="submit">
{{ t('buttons.SUBMIT') }}
</button>
<button class="danger" @click.prevent="updateDisplayModal(true)">
{{ t('buttons.DELETE_MY_ACCOUNT') }}
</button>
<button class="cancel" @click.prevent="$router.go(-1)">
{{ t('buttons.CANCEL') }}
</button>
</div>
</form>
</div>
</template>
</Card>
</div>
</template>
<script lang="ts">
import { format } from 'date-fns'
import {
ComputedRef,
PropType,
Ref,
computed,
defineComponent,
reactive,
ref,
onMounted,
} from 'vue'
import { useI18n } from 'vue-i18n'
import Card from '@/components/Common/Card.vue'
import CustomTextArea from '@/components/Common/CustomTextArea.vue'
import ErrorMessage from '@/components/Common/ErrorMessage.vue'
import Modal from '@/components/Common/Modal.vue'
import { ROOT_STORE, USER_STORE } from '@/store/constants'
import { IAuthUserProfile, IUserPayload } from '@/types/user'
import { useStore } from '@/use/useStore'
export default defineComponent({
name: 'ProfileEdition',
components: {
Card,
CustomTextArea,
ErrorMessage,
Modal,
},
props: {
user: {
type: Object as PropType<IAuthUserProfile>,
required: true,
},
},
setup(props) {
const { t, availableLocales } = useI18n()
const store = useStore()
const userForm: IUserPayload = reactive({
password: '',
password_conf: '',
first_name: '',
last_name: '',
birth_date: '',
location: '',
bio: '',
language: '',
timezone: 'Europe/Paris',
weekm: false,
})
const availableLanguages = availableLocales.map((l) => {
return { label: l.toUpperCase(), value: l }
})
const weekStart = [
{
label: 'MONDAY',
value: true,
},
{
label: 'SUNDAY',
value: false,
},
]
const registrationDate = computed(() =>
props.user.created_at
? format(new Date(props.user.created_at), 'dd/MM/yyyy HH:mm')
: ''
)
const loading = computed(
() => store.getters[USER_STORE.GETTERS.USER_LOADING]
)
const errorMessages: ComputedRef<string | string[] | null> = computed(
() => store.getters[ROOT_STORE.GETTERS.ERROR_MESSAGES]
)
let displayModal: Ref<boolean> = ref(false)
onMounted(() => {
if (props.user) {
updateUserForm(props.user)
}
})
function updateUserForm(user: IAuthUserProfile) {
userForm.first_name = user.first_name ? user.first_name : ''
userForm.last_name = user.last_name ? user.last_name : ''
userForm.birth_date = user.birth_date
? format(new Date(user.birth_date), 'yyyy-MM-dd')
: ''
userForm.location = user.location ? user.location : ''
userForm.bio = user.bio ? user.bio : ''
userForm.language = user.language ? user.language : 'en'
userForm.timezone = user.timezone ? user.timezone : 'Europe/Paris'
userForm.weekm = user.weekm ? user.weekm : false
}
function updateBio(value: string) {
userForm.bio = value
}
function updateProfile() {
store.dispatch(USER_STORE.ACTIONS.UPDATE_USER_PROFILE, userForm)
}
function updateDisplayModal(value: boolean) {
displayModal.value = value
}
function deleteAccount(username: string) {
store.dispatch(USER_STORE.ACTIONS.DELETE_ACCOUNT, { username })
}
return {
availableLanguages,
displayModal,
errorMessages,
loading,
registrationDate,
t,
userForm,
weekStart,
deleteAccount,
updateBio,
updateDisplayModal,
updateProfile,
}
},
})
</script>
<style lang="scss" scoped>
@import '~@/scss/base.scss';
#user-profile-edition {
margin: auto;
width: 700px;
@media screen and (max-width: $medium-limit) {
width: 100%;
margin: 0 auto 50px auto;
}
.profile-form {
display: flex;
flex-direction: column;
hr {
border-color: var(--card-border-color);
border-width: 1px 0 0 0;
}
.form-items {
display: flex;
flex-direction: column;
input {
margin: $default-padding * 0.5 0;
}
select {
height: 35px;
padding: $default-padding * 0.5 0;
}
::v-deep(.custom-textarea) {
textarea {
padding: $default-padding * 0.5;
}
}
.form-item {
display: flex;
flex-direction: column;
padding: $default-padding;
}
.birth-date {
height: 20px;
}
}
.form-buttons {
display: flex;
padding: $default-padding 0;
gap: $default-padding;
}
}
}
</style>

View File

@ -0,0 +1,208 @@
<template>
<div id="user-infos-edition">
<Modal
v-if="displayModal"
:title="t('common.CONFIRMATION')"
:message="t('user.CONFIRM_ACCOUNT_DELETION')"
@confirmAction="deleteAccount(user.username)"
@cancelAction="updateDisplayModal(false)"
/>
<div class="profile-form form-box">
<ErrorMessage :message="errorMessages" v-if="errorMessages" />
<form @submit.prevent="updateProfile">
<label class="form-items" for="email">
{{ t('user.EMAIL') }}
<input id="email" :value="user.email" disabled />
</label>
<label class="form-items" for="registrationDate">
{{ t('user.PROFILE.REGISTRATION_DATE') }}
<input id="registrationDate" :value="registrationDate" disabled />
</label>
<label class="form-items" for="password">
{{ t('user.PASSWORD') }}
<input
id="password"
type="password"
v-model="userForm.password"
:disabled="loading"
/>
</label>
<label class="form-items" for="passwordConfirmation">
{{ t('user.PASSWORD_CONFIRMATION') }}
<input
id="passwordConfirmation"
type="password"
v-model="userForm.password_conf"
:disabled="loading"
/>
</label>
<hr />
<label class="form-items" for="first_name">
{{ t('user.PROFILE.FIRST_NAME') }}
<input
id="first_name"
v-model="userForm.first_name"
:disabled="loading"
/>
</label>
<label class="form-items" for="last_name">
{{ t('user.PROFILE.LAST_NAME') }}
<input id="last_name" v-model="userForm.last_name" />
</label>
<label class="form-items" for="birth_date">
{{ t('user.PROFILE.BIRTH_DATE') }}
<input
id="birth_date"
type="date"
class="birth-date"
v-model="userForm.birth_date"
:disabled="loading"
/>
</label>
<label class="form-items" for="location">
{{ t('user.PROFILE.LOCATION') }}
<input
id="location"
v-model="userForm.location"
:disabled="loading"
/>
</label>
<label class="form-items">
{{ t('user.PROFILE.BIO') }}
<CustomTextArea
name="bio"
:charLimit="200"
:input="userForm.bio"
:disabled="loading"
@updateValue="updateBio"
/>
</label>
<div class="form-buttons">
<button class="confirm" type="submit">
{{ t('buttons.SUBMIT') }}
</button>
<button class="cancel" @click.prevent="$router.go(-1)">
{{ t('buttons.CANCEL') }}
</button>
<button class="danger" @click.prevent="updateDisplayModal(true)">
{{ t('buttons.DELETE_MY_ACCOUNT') }}
</button>
</div>
</form>
</div>
</div>
</template>
<script lang="ts">
import { format } from 'date-fns'
import {
ComputedRef,
PropType,
Ref,
computed,
defineComponent,
reactive,
ref,
onMounted,
} from 'vue'
import { useI18n } from 'vue-i18n'
import CustomTextArea from '@/components/Common/CustomTextArea.vue'
import ErrorMessage from '@/components/Common/ErrorMessage.vue'
import Modal from '@/components/Common/Modal.vue'
import { ROOT_STORE, USER_STORE } from '@/store/constants'
import { IAuthUserProfile, IUserPayload } from '@/types/user'
import { useStore } from '@/use/useStore'
export default defineComponent({
name: 'UserInfosEdition',
components: {
CustomTextArea,
ErrorMessage,
Modal,
},
props: {
user: {
type: Object as PropType<IAuthUserProfile>,
required: true,
},
},
setup(props) {
const { t } = useI18n()
const store = useStore()
const userForm: IUserPayload = reactive({
password: '',
password_conf: '',
first_name: '',
last_name: '',
birth_date: '',
location: '',
bio: '',
})
const registrationDate = computed(() =>
props.user.created_at
? format(new Date(props.user.created_at), 'dd/MM/yyyy HH:mm')
: ''
)
const loading = computed(
() => store.getters[USER_STORE.GETTERS.USER_LOADING]
)
const errorMessages: ComputedRef<string | string[] | null> = computed(
() => store.getters[ROOT_STORE.GETTERS.ERROR_MESSAGES]
)
let displayModal: Ref<boolean> = ref(false)
onMounted(() => {
if (props.user) {
updateUserForm(props.user)
}
})
function updateUserForm(user: IAuthUserProfile) {
userForm.first_name = user.first_name ? user.first_name : ''
userForm.last_name = user.last_name ? user.last_name : ''
userForm.birth_date = user.birth_date
? format(new Date(user.birth_date), 'yyyy-MM-dd')
: ''
userForm.location = user.location ? user.location : ''
userForm.bio = user.bio ? user.bio : ''
}
function updateBio(value: string) {
userForm.bio = value
}
function updateProfile() {
store.dispatch(USER_STORE.ACTIONS.UPDATE_USER_PROFILE, userForm)
}
function updateDisplayModal(value: boolean) {
displayModal.value = value
}
function deleteAccount(username: string) {
store.dispatch(USER_STORE.ACTIONS.DELETE_ACCOUNT, { username })
}
return {
displayModal,
errorMessages,
loading,
registrationDate,
t,
userForm,
deleteAccount,
updateBio,
updateDisplayModal,
updateProfile,
}
},
})
</script>
<style lang="scss">
@import '~@/scss/base.scss';
.form-buttons {
flex-direction: row;
@media screen and (max-width: $x-small-limit) {
flex-direction: column;
}
}
</style>

View File

@ -0,0 +1,141 @@
<template>
<div id="user-picture-edition">
<div class="user-picture-form">
<ErrorMessage :message="errorMessages" v-if="errorMessages" />
<UserPicture :user="user" />
<form @submit.prevent="updateUserPicture">
<input
type="file"
name="picture"
accept=".png,.jpg,.gif"
@input="updatePictureFile"
/>
<div class="picture-buttons">
<button type="submit" :disabled="!pictureFile">
{{ t('user.PROFILE.PICTURE_UPDATE') }}
</button>
<button class="danger" v-if="user.picture" @click="deleteUserPicture">
{{ t('user.PROFILE.PICTURE_REMOVE') }}
</button>
<button class="cancel" @click="$router.push('/profile')">
{{ t('user.PROFILE.BACK_TO_PROFILE') }}
</button>
</div>
<span>{{ t('workouts.MAX_SIZE') }}: {{ fileSizeLimit }}</span>
</form>
</div>
</div>
</template>
<script lang="ts">
import {
ComputedRef,
PropType,
Ref,
defineComponent,
computed,
ref,
} from 'vue'
import { useI18n } from 'vue-i18n'
import ErrorMessage from '@/components/Common/ErrorMessage.vue'
import UserPicture from '@/components/User/UserPicture.vue'
import { ROOT_STORE, USER_STORE } from '@/store/constants'
import { IAppConfig } from '@/types/application'
import { IAuthUserProfile } from '@/types/user'
import { useStore } from '@/use/useStore'
import { getReadableFileSize } from '@/utils/files'
export default defineComponent({
name: 'UserPictureEdition',
components: {
ErrorMessage,
UserPicture,
},
props: {
user: {
type: Object as PropType<IAuthUserProfile>,
required: true,
},
},
setup() {
const { t } = useI18n()
const store = useStore()
const errorMessages: ComputedRef<string | string[] | null> = computed(
() => store.getters[ROOT_STORE.GETTERS.ERROR_MESSAGES]
)
const appConfig: ComputedRef<IAppConfig> = computed(
() => store.getters[ROOT_STORE.GETTERS.APP_CONFIG]
)
const fileSizeLimit = appConfig.value.max_single_file_size
? getReadableFileSize(appConfig.value.max_single_file_size)
: ''
let pictureFile: Ref<File | null> = ref(null)
function deleteUserPicture() {
store.dispatch(USER_STORE.ACTIONS.DELETE_PICTURE)
}
function updatePictureFile(event: Event & { target: HTMLInputElement }) {
if (event.target.files) {
pictureFile.value = event.target.files[0]
}
}
function updateUserPicture() {
if (pictureFile.value) {
store.dispatch(USER_STORE.ACTIONS.UPDATE_USER_PICTURE, {
picture: pictureFile.value,
})
}
}
return {
errorMessages,
fileSizeLimit,
pictureFile,
t,
deleteUserPicture,
updateUserPicture,
updatePictureFile,
}
},
})
</script>
<style lang="scss" scoped>
@import '~@/scss/base.scss';
#user-picture-edition {
.user-picture-form {
display: flex;
flex-direction: column;
form {
display: flex;
flex-direction: column;
gap: $default-padding;
justify-content: flex-start;
input {
margin-top: $default-margin;
padding: $default-padding * 0.5;
}
span {
font-style: italic;
font-size: 0.9em;
padding-left: $default-padding * 0.5;
}
}
.picture-buttons {
display: flex;
flex-direction: row;
align-items: center;
gap: $default-padding;
@media screen and (max-width: $x-small-limit) {
flex-direction: column;
align-items: stretch;
}
}
}
}
</style>

View File

@ -0,0 +1,132 @@
<template>
<div id="user-preferences-edition">
<div class="profile-form form-box">
<ErrorMessage :message="errorMessages" v-if="errorMessages" />
<form @submit.prevent="updateProfile">
<label class="form-items">
{{ t('user.PROFILE.LANGUAGE') }}
<select id="language" v-model="userForm.language" :disabled="loading">
<option
v-for="lang in availableLanguages"
:value="lang.value"
:key="lang.value"
>
{{ lang.label }}
</option>
</select>
</label>
<label class="form-items" for="timezone">
{{ t('user.PROFILE.TIMEZONE') }}
<input
id="timezone"
v-model="userForm.timezone"
:disabled="loading"
/>
</label>
<label class="form-items">
{{ t('user.PROFILE.FIRST_DAY_OF_WEEK') }}
<select id="weekm" v-model="userForm.weekm" :disabled="loading">
<option
v-for="start in weekStart"
:value="start.value"
:key="start.value"
>
{{ t(`user.PROFILE.${start.label}`) }}
</option>
</select>
</label>
<div class="form-buttons">
<button class="confirm" type="submit">
{{ t('buttons.SUBMIT') }}
</button>
<button class="cancel" @click.prevent="$router.go(-1)">
{{ t('buttons.CANCEL') }}
</button>
</div>
</form>
</div>
</div>
</template>
<script lang="ts">
import {
ComputedRef,
PropType,
computed,
defineComponent,
reactive,
onMounted,
} from 'vue'
import { useI18n } from 'vue-i18n'
import ErrorMessage from '@/components/Common/ErrorMessage.vue'
import { ROOT_STORE, USER_STORE } from '@/store/constants'
import { IAuthUserProfile, IUserPreferencesPayload } from '@/types/user'
import { useStore } from '@/use/useStore'
export default defineComponent({
name: 'UserPreferencesEdition',
components: {
ErrorMessage,
},
props: {
user: {
type: Object as PropType<IAuthUserProfile>,
required: true,
},
},
setup(props) {
const { t, availableLocales } = useI18n()
const store = useStore()
const userForm: IUserPreferencesPayload = reactive({
language: '',
timezone: 'Europe/Paris',
weekm: false,
})
const availableLanguages = availableLocales.map((l) => {
return { label: l.toUpperCase(), value: l }
})
const weekStart = [
{
label: 'MONDAY',
value: true,
},
{
label: 'SUNDAY',
value: false,
},
]
const loading = computed(
() => store.getters[USER_STORE.GETTERS.USER_LOADING]
)
const errorMessages: ComputedRef<string | string[] | null> = computed(
() => store.getters[ROOT_STORE.GETTERS.ERROR_MESSAGES]
)
onMounted(() => {
if (props.user) {
updateUserForm(props.user)
}
})
function updateUserForm(user: IAuthUserProfile) {
userForm.language = user.language ? user.language : 'en'
userForm.timezone = user.timezone ? user.timezone : 'Europe/Paris'
userForm.weekm = user.weekm ? user.weekm : false
}
function updateProfile() {
store.dispatch(USER_STORE.ACTIONS.UPDATE_USER_PREFERENCES, userForm)
}
return {
availableLanguages,
errorMessages,
loading,
t,
userForm,
weekStart,
updateProfile,
}
},
})
</script>

View File

@ -0,0 +1,76 @@
<template>
<div id="user-profile-edition">
<Card>
<template #title>{{ t('user.PROFILE.EDITION') }}</template>
<template #content>
<UserProfileTabs
:tabs="tabs"
:selectedTab="tab"
:edition="true"
:disabled="loading"
/>
<UserInfosEdition v-if="tab === 'PROFILE'" :user="user" />
<UserPreferencesEdition v-if="tab === 'PREFERENCES'" :user="user" />
<UserPictureEdition v-if="tab === 'PICTURE'" :user="user" />
</template>
</Card>
</div>
</template>
<script lang="ts">
import { PropType, defineComponent, ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import Card from '@/components/Common/Card.vue'
import UserInfosEdition from '@/components/User/ProfileEdition/UserInfosEdition.vue'
import UserPictureEdition from '@/components/User/ProfileEdition/UserPictureEdition.vue'
import UserPreferencesEdition from '@/components/User/ProfileEdition/UserPreferencesEdition.vue'
import UserProfileTabs from '@/components/User/UserProfileTabs.vue'
import { USER_STORE } from '@/store/constants'
import { IAuthUserProfile } from '@/types/user'
import { useStore } from '@/use/useStore'
export default defineComponent({
name: 'ProfileEdition',
components: {
Card,
UserInfosEdition,
UserPictureEdition,
UserPreferencesEdition,
UserProfileTabs,
},
props: {
user: {
type: Object as PropType<IAuthUserProfile>,
required: true,
},
tab: {
type: String,
required: true,
},
},
setup(props) {
const { t } = useI18n()
const store = useStore()
const tabs = ['PROFILE', 'PICTURE', 'PREFERENCES']
const selectedTab = ref(props.tab)
const loading = computed(
() => store.getters[USER_STORE.GETTERS.USER_LOADING]
)
return { loading, selectedTab, t, tabs }
},
})
</script>
<style lang="scss" scoped>
@import '~@/scss/base.scss';
#user-profile-edition {
margin: auto;
width: 700px;
@media screen and (max-width: $medium-limit) {
width: 100%;
margin: 0 auto 50px auto;
}
}
</style>

View File

@ -0,0 +1,61 @@
<template>
<div class="user-picture">
<img
v-if="authUserPictureUrl !== ''"
class="nav-profile-user-img"
:alt="t('user.USER_PICTURE')"
:src="authUserPictureUrl"
/>
<div v-else class="no-picture">
<i class="fa fa-user-circle-o" aria-hidden="true" />
</div>
</div>
</template>
<script lang="ts">
import { PropType, computed, defineComponent } from 'vue'
import { useI18n } from 'vue-i18n'
import { IAuthUserProfile } from '@/types/user'
import { getApiUrl } from '@/utils'
export default defineComponent({
name: 'UserPicture',
props: {
user: {
type: Object as PropType<IAuthUserProfile>,
required: true,
},
},
setup(props) {
const { t } = useI18n()
return {
authUserPictureUrl: computed(() =>
props.user.picture
? `${getApiUrl()}users/${props.user.username}/picture?${Date.now()}`
: ''
),
t,
}
},
})
</script>
<style lang="scss">
@import '~@/scss/base.scss';
.user-picture {
display: flex;
justify-content: center;
align-items: center;
min-width: 30%;
img {
border-radius: 50%;
height: 90px;
width: 90px;
}
.no-picture {
color: var(--app-a-color);
font-size: 5.5em;
}
}
</style>

View File

@ -0,0 +1,69 @@
<template>
<div class="profile-tabs custom-checkboxes-group">
<div class="profile-tabs-checkboxes custom-checkboxes">
<div v-for="tab in tabs" class="profile-tab custom-checkbox" :key="tab">
<label>
<input
type="radio"
:id="tab"
:name="tab"
:checked="selectedTab === tab"
:disabled="disabled"
@input="$router.push(getPath(tab))"
/>
<span>{{ t(`user.PROFILE.TABS.${tab}`) }}</span>
</label>
</div>
</div>
</div>
</template>
<script lang="ts">
import { PropType, defineComponent } from 'vue'
import { useI18n } from 'vue-i18n'
export default defineComponent({
name: 'UserProfileTabs',
props: {
tabs: {
type: Object as PropType<string[]>,
required: true,
},
selectedTab: {
type: String,
required: true,
},
edition: {
type: Boolean,
required: true,
},
disabled: {
type: Boolean,
default: false,
},
},
setup(props) {
const { t } = useI18n()
function getPath(tab: string) {
switch (tab) {
case 'PICTURE':
return '/profile/edit/picture'
case 'PREFERENCES':
return `/profile${props.edition ? '/edit' : ''}/preferences`
default:
case 'PROFILE':
return `/profile${props.edition ? '/edit' : ''}`
}
}
return { t, getPath }
},
})
</script>
<style lang="scss">
@import '~@/scss/base.scss';
.profile-tabs {
margin: $default-margin 0 $default-margin;
}
</style>

View File

@ -1,6 +1,6 @@
{ {
"EMAIL": "Email",
"CONFIRM_ACCOUNT_DELETION": "Are you sure you want to delete your account? All data will be deleted, this cannot be undone", "CONFIRM_ACCOUNT_DELETION": "Are you sure you want to delete your account? All data will be deleted, this cannot be undone",
"EMAIL": "Email",
"LANGUAGE": "Language", "LANGUAGE": "Language",
"LOGIN": "Login", "LOGIN": "Login",
"LOGOUT": "Logout", "LOGOUT": "Logout",
@ -8,9 +8,11 @@
"PASSWORD_CONFIRM": "Confirm Password", "PASSWORD_CONFIRM": "Confirm Password",
"PASSWORD_CONFIRMATION": "Password confirmation", "PASSWORD_CONFIRMATION": "Password confirmation",
"PROFILE": { "PROFILE": {
"BACK_TO_PROFILE": "Back to profile",
"BIO": "Bio", "BIO": "Bio",
"BIRTH_DATE": "Birth date", "BIRTH_DATE": "Birth date",
"EDIT": "Edit profile", "EDIT": "Edit profile",
"EDIT_PREFERENCES": "Edit preferences",
"EDITION": "Profile edition", "EDITION": "Profile edition",
"FIRST_NAME": "First name", "FIRST_NAME": "First name",
"FIRST_DAY_OF_WEEK": "First day of week", "FIRST_DAY_OF_WEEK": "First day of week",
@ -19,8 +21,15 @@
"LOCATION": "Location", "LOCATION": "Location",
"MONDAY": "Monday", "MONDAY": "Monday",
"PICTURE": "Picture", "PICTURE": "Picture",
"PICTURE_UPDATE": "Update picture",
"PICTURE_REMOVE": "Remove picture",
"REGISTRATION_DATE": "Registration date", "REGISTRATION_DATE": "Registration date",
"SUNDAY": "Sunday", "SUNDAY": "Sunday",
"TABS": {
"PICTURE": "picture",
"PREFERENCES": "preferences",
"PROFILE": "profile"
},
"TIMEZONE": "Timezone" "TIMEZONE": "Timezone"
}, },
"REGISTER": "Register", "REGISTER": "Register",

View File

@ -1,5 +1,4 @@
{ {
"CONFIRM-PASSWORD": "Confirmation du mot de passe",
"CONFIRM_ACCOUNT_DELETION": "Etes-vous sûr de vouloir supprimer votre compte ? Toutes les données seront définitivement effacés.", "CONFIRM_ACCOUNT_DELETION": "Etes-vous sûr de vouloir supprimer votre compte ? Toutes les données seront définitivement effacés.",
"EMAIL": "Email", "EMAIL": "Email",
"LANGUAGE": "Langue", "LANGUAGE": "Langue",
@ -9,9 +8,11 @@
"PASSWORD_CONFIRM": "Confirmation du mot de passe", "PASSWORD_CONFIRM": "Confirmation du mot de passe",
"PASSWORD_CONFIRMATION": "Confirmation du mot de passe", "PASSWORD_CONFIRMATION": "Confirmation du mot de passe",
"PROFILE": { "PROFILE": {
"BACK_TO_PROFILE": "Revenir au profil",
"BIO": "Bio", "BIO": "Bio",
"BIRTH_DATE": "Date de naissance", "BIRTH_DATE": "Date de naissance",
"EDIT": "Modifier le profil", "EDIT": "Modifier le profil",
"EDIT_PREFERENCES": "Modifier les préférences",
"EDITION": "Mise à jour du profil", "EDITION": "Mise à jour du profil",
"FIRST_DAY_OF_WEEK": "Premier jour de la semaine", "FIRST_DAY_OF_WEEK": "Premier jour de la semaine",
"FIRST_NAME": "Prénom", "FIRST_NAME": "Prénom",
@ -19,9 +20,16 @@
"LAST_NAME": "Nom", "LAST_NAME": "Nom",
"LOCATION": "Lieu", "LOCATION": "Lieu",
"MONDAY": "Lundi", "MONDAY": "Lundi",
"PICTURE": "Avatar", "PICTURE": "Image de profil",
"PICTURE_UPDATE": "Mettre à jour l'image",
"PICTURE_REMOVE": "Supprimer",
"REGISTRATION_DATE": "Date d'inscription", "REGISTRATION_DATE": "Date d'inscription",
"SUNDAY": "Dimanche", "SUNDAY": "Dimanche",
"TABS": {
"PICTURE": "image",
"PREFERENCES": "préférences",
"PROFILE": "profil"
},
"TIMEZONE": "Fuseau horaire" "TIMEZONE": "Fuseau horaire"
}, },
"REGISTER": "S'inscrire", "REGISTER": "S'inscrire",

View File

@ -34,13 +34,31 @@ const routes: Array<RouteRecordRaw> = [
path: '/profile', path: '/profile',
name: 'Profile', name: 'Profile',
component: ProfileView, component: ProfileView,
props: { edition: false }, props: { edition: false, tab: 'PROFILE' },
},
{
path: '/profile/edit/picture',
name: 'UserPictureEdition',
component: ProfileView,
props: { edition: true, tab: 'PICTURE' },
},
{
path: '/profile/preferences',
name: 'UserPreferences',
component: ProfileView,
props: { edition: false, tab: 'PREFERENCES' },
},
{
path: '/profile/edit/preferences',
name: 'UserPreferencesEdition',
component: ProfileView,
props: { edition: true, tab: 'PREFERENCES' },
}, },
{ {
path: '/profile/edit', path: '/profile/edit',
name: 'ProfileEdition', name: 'ProfileEdition',
component: ProfileView, component: ProfileView,
props: { edition: true }, props: { edition: true, tab: 'PROFILE' },
}, },
{ {
path: '/statistics', path: '/statistics',

View File

@ -149,3 +149,76 @@ button {
text-align: center; text-align: center;
vertical-align: center; vertical-align: center;
} }
.custom-checkboxes-group {
display: flex;
justify-content: space-around;
.custom-checkboxes {
display: inline-flex;
@media screen and (max-width: $xx-small-limit) {
display: flex;
flex-direction: column;
align-items: center;
gap: $default-padding * .5;
}
.custom-checkbox {
label {
font-weight: normal;
float: left;
padding: 0 5px;
cursor: pointer;
}
label input {
display: none;
}
label span {
border: solid 1px var(--custom-checkbox-border-color);
border-radius: 5px;
display: block;
font-size: 0.9em;
padding: 2px 6px;
text-align: center;
}
input:checked + span {
background-color: var(--custom-checkbox-checked-bg-color);
color: var(--custom-checkbox-checked-color);
}
}
}
}
.description-list {
dl {
overflow: hidden;
width: 100%;
padding: 0 $default-padding;
dt {
font-weight: bold;
float: left;
width: 25%;
}
dd {
float: left;
}
}
@media screen and (max-width: $x-small-limit) {
dl {
overflow: auto;
width: initial;
dt {
font-weight: bold;
float: none;
width: initial;
}
dd {
float: none;
}
}
}
}

View File

@ -20,9 +20,9 @@
--card-border-color: #c4c7cf; --card-border-color: #c4c7cf;
--input-border-color: #9da3af; --input-border-color: #9da3af;
--time-frame-border-color: #9da3af; --custom-checkbox-border-color: #9da3af;
--time-frame-checked-bg-color: #9da3af; --custom-checkbox-checked-bg-color: #9da3af;
--time-frame-checked-color: #FFFFFF; --custom-checkbox-checked-color: #FFFFFF;
--calendar-border-color: #c4c7cf; --calendar-border-color: #c4c7cf;
--calendar-week-end-color: #f5f5f5; --calendar-week-end-color: #f5f5f5;

View File

@ -5,6 +5,7 @@ $container-width: 1140px;
$medium-limit: 1000px; $medium-limit: 1000px;
$small-limit: 700px; $small-limit: 700px;
$x-small-limit: 500px; $x-small-limit: 500px;
$xx-small-limit: 300px;
/* /*
* === HEIGHT === * === HEIGHT ===

View File

@ -2,6 +2,7 @@ import { ActionContext, ActionTree } from 'vuex'
import authApi from '@/api/authApi' import authApi from '@/api/authApi'
import api from '@/api/defaultApi' import api from '@/api/defaultApi'
import createI18n from '@/i18n'
import router from '@/router' import router from '@/router'
import { import {
ROOT_STORE, ROOT_STORE,
@ -16,9 +17,13 @@ import {
ILoginOrRegisterData, ILoginOrRegisterData,
IUserDeletionPayload, IUserDeletionPayload,
IUserPayload, IUserPayload,
IUserPicturePayload,
IUserPreferencesPayload,
} from '@/types/user' } from '@/types/user'
import { handleError } from '@/utils' import { handleError } from '@/utils'
const { locale } = createI18n.global
export const actions: ActionTree<IUserState, IRootState> & IUserActions = { export const actions: ActionTree<IUserState, IRootState> & IUserActions = {
[USER_STORE.ACTIONS.CHECK_AUTH_USER]( [USER_STORE.ACTIONS.CHECK_AUTH_USER](
context: ActionContext<IUserState, IRootState> context: ActionContext<IUserState, IRootState>
@ -46,6 +51,13 @@ export const actions: ActionTree<IUserState, IRootState> & IUserActions = {
USER_STORE.MUTATIONS.UPDATE_AUTH_USER_PROFILE, USER_STORE.MUTATIONS.UPDATE_AUTH_USER_PROFILE,
res.data.data res.data.data
) )
if (res.data.data.language) {
context.commit(
ROOT_STORE.MUTATIONS.UPDATE_LANG,
res.data.data.language
)
locale.value = res.data.data.language
}
context.dispatch(SPORTS_STORE.ACTIONS.GET_SPORTS) context.dispatch(SPORTS_STORE.ACTIONS.GET_SPORTS)
} else { } else {
handleError(context, null) handleError(context, null)
@ -108,6 +120,61 @@ export const actions: ActionTree<IUserState, IRootState> & IUserActions = {
context.commit(USER_STORE.MUTATIONS.UPDATE_USER_LOADING, false) context.commit(USER_STORE.MUTATIONS.UPDATE_USER_LOADING, false)
) )
}, },
[USER_STORE.ACTIONS.UPDATE_USER_PREFERENCES](
context: ActionContext<IUserState, IRootState>,
payload: IUserPreferencesPayload
): void {
context.commit(ROOT_STORE.MUTATIONS.EMPTY_ERROR_MESSAGES)
context.commit(USER_STORE.MUTATIONS.UPDATE_USER_LOADING, true)
authApi
.post('auth/profile/edit/preferences', payload)
.then((res) => {
if (res.data.status === 'success') {
context.commit(
USER_STORE.MUTATIONS.UPDATE_AUTH_USER_PROFILE,
res.data.data
)
router.push('/profile/preferences')
} else {
handleError(context, null)
}
})
.catch((error) => handleError(context, error))
.finally(() =>
context.commit(USER_STORE.MUTATIONS.UPDATE_USER_LOADING, false)
)
},
[USER_STORE.ACTIONS.UPDATE_USER_PICTURE](
context: ActionContext<IUserState, IRootState>,
payload: IUserPicturePayload
): void {
context.commit(ROOT_STORE.MUTATIONS.EMPTY_ERROR_MESSAGES)
context.commit(USER_STORE.MUTATIONS.UPDATE_USER_LOADING, true)
if (!payload.picture) {
throw new Error('No file part')
}
const form = new FormData()
form.append('file', payload.picture)
authApi
.post('auth/picture', form, {
headers: {
'content-type': 'multipart/form-data',
},
})
.then((res) => {
if (res.data.status === 'success') {
context
.dispatch(USER_STORE.ACTIONS.GET_USER_PROFILE)
.then(() => router.push('/profile'))
} else {
handleError(context, null)
}
})
.catch((error) => handleError(context, error))
.finally(() =>
context.commit(USER_STORE.MUTATIONS.UPDATE_USER_LOADING, false)
)
},
[USER_STORE.ACTIONS.DELETE_ACCOUNT]( [USER_STORE.ACTIONS.DELETE_ACCOUNT](
context: ActionContext<IUserState, IRootState>, context: ActionContext<IUserState, IRootState>,
payload: IUserDeletionPayload payload: IUserDeletionPayload
@ -126,4 +193,25 @@ export const actions: ActionTree<IUserState, IRootState> & IUserActions = {
}) })
.catch((error) => handleError(context, error)) .catch((error) => handleError(context, error))
}, },
[USER_STORE.ACTIONS.DELETE_PICTURE](
context: ActionContext<IUserState, IRootState>
): void {
context.commit(ROOT_STORE.MUTATIONS.EMPTY_ERROR_MESSAGES)
context.commit(USER_STORE.MUTATIONS.UPDATE_USER_LOADING, true)
authApi
.delete(`auth/picture`)
.then((res) => {
if (res.status === 204) {
context
.dispatch(USER_STORE.ACTIONS.GET_USER_PROFILE)
.then(() => router.push('/profile'))
} else {
handleError(context, null)
}
})
.catch((error) => handleError(context, error))
.finally(() =>
context.commit(USER_STORE.MUTATIONS.UPDATE_USER_LOADING, false)
)
},
} }

View File

@ -1,10 +1,13 @@
export enum UserActions { export enum UserActions {
CHECK_AUTH_USER = 'CHECK_AUTH_USER', CHECK_AUTH_USER = 'CHECK_AUTH_USER',
DELETE_ACCOUNT = 'DELETE_ACCOUNT', DELETE_ACCOUNT = 'DELETE_ACCOUNT',
DELETE_PICTURE = 'DELETE_PICTURE',
GET_USER_PROFILE = 'GET_USER_PROFILE', GET_USER_PROFILE = 'GET_USER_PROFILE',
LOGIN_OR_REGISTER = 'LOGIN_OR_REGISTER', LOGIN_OR_REGISTER = 'LOGIN_OR_REGISTER',
LOGOUT = 'LOGOUT', LOGOUT = 'LOGOUT',
UPDATE_USER_PICTURE = 'UPDATE_USER_PICTURE',
UPDATE_USER_PROFILE = 'UPDATE_USER_PROFILE', UPDATE_USER_PROFILE = 'UPDATE_USER_PROFILE',
UPDATE_USER_PREFERENCES = 'UPDATE_USER_PREFERENCES',
} }
export enum UserGetters { export enum UserGetters {

View File

@ -12,6 +12,8 @@ import {
ILoginOrRegisterData, ILoginOrRegisterData,
IUserDeletionPayload, IUserDeletionPayload,
IUserPayload, IUserPayload,
IUserPicturePayload,
IUserPreferencesPayload,
} from '@/types/user' } from '@/types/user'
export interface IUserState { export interface IUserState {
@ -43,10 +45,24 @@ export interface IUserActions {
payload: IUserPayload payload: IUserPayload
): void ): void
[USER_STORE.ACTIONS.UPDATE_USER_PREFERENCES](
context: ActionContext<IUserState, IRootState>,
payload: IUserPreferencesPayload
): void
[USER_STORE.ACTIONS.UPDATE_USER_PICTURE](
context: ActionContext<IUserState, IRootState>,
payload: IUserPicturePayload
): void
[USER_STORE.ACTIONS.DELETE_ACCOUNT]( [USER_STORE.ACTIONS.DELETE_ACCOUNT](
context: ActionContext<IUserState, IRootState>, context: ActionContext<IUserState, IRootState>,
payload: IUserDeletionPayload payload: IUserDeletionPayload
): void ): void
[USER_STORE.ACTIONS.DELETE_PICTURE](
context: ActionContext<IUserState, IRootState>
): void
} }
export interface IUserGetters { export interface IUserGetters {

View File

@ -26,15 +26,22 @@ export interface IUserPayload {
bio: string bio: string
birth_date: string birth_date: string
first_name: string first_name: string
language: string
last_name: string last_name: string
location: string location: string
timezone: string
weekm: boolean
password: string password: string
password_conf: string password_conf: string
} }
export interface IUserPreferencesPayload {
language: string
timezone: string
weekm: boolean
}
export interface IUserPicturePayload {
picture: File
}
export interface IUserDeletionPayload { export interface IUserDeletionPayload {
username: string username: string
} }

View File

@ -1,7 +1,7 @@
<template> <template>
<div id="profile" class="container" v-if="authUser.username"> <div id="profile" class="container" v-if="authUser.username">
<ProfileEdition :user="authUser" v-if="edition" /> <ProfileEdition :user="authUser" :tab="tab" v-if="edition" />
<Profile :user="authUser" v-else /> <Profile :user="authUser" :tab="tab" v-else />
</div> </div>
</template> </template>
@ -9,7 +9,7 @@
import { computed, ComputedRef, defineComponent } from 'vue' import { computed, ComputedRef, defineComponent } from 'vue'
import Profile from '@/components/User/ProfileDisplay/index.vue' import Profile from '@/components/User/ProfileDisplay/index.vue'
import ProfileEdition from '@/components/User/ProfileEdition.vue' import ProfileEdition from '@/components/User/ProfileEdition/index.vue'
import { USER_STORE } from '@/store/constants' import { USER_STORE } from '@/store/constants'
import { IAuthUserProfile } from '@/types/user' import { IAuthUserProfile } from '@/types/user'
import { useStore } from '@/use/useStore' import { useStore } from '@/use/useStore'
@ -25,6 +25,10 @@
type: Boolean, type: Boolean,
required: true, required: true,
}, },
tab: {
type: String,
required: true,
},
}, },
setup() { setup() {
const store = useStore() const store = useStore()
@ -43,5 +47,50 @@
#profile { #profile {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
::v-deep(.profile-form) {
display: flex;
flex-direction: column;
hr {
border-color: var(--card-border-color);
border-width: 1px 0 0 0;
}
.form-items {
display: flex;
flex-direction: column;
input {
margin: $default-padding * 0.5 0;
}
select {
height: 35px;
padding: $default-padding * 0.5 0;
}
::v-deep(.custom-textarea) {
textarea {
padding: $default-padding * 0.5;
}
}
.form-item {
display: flex;
flex-direction: column;
padding: $default-padding;
}
.birth-date {
height: 20px;
}
}
.form-buttons {
display: flex;
margin-top: $default-margin;
padding: $default-padding 0;
gap: $default-padding;
}
}
} }
</style> </style>