name/bio/avatar/banner edit support

This commit is contained in:
Henry Jameson 2025-08-04 11:10:43 +03:00
commit f79c61c4e7
10 changed files with 203 additions and 57 deletions

View file

@ -22,6 +22,9 @@ const ImageCropper = {
},
cancelButtonLabel: {
type: String
},
aspectRatio: {
type: Number
}
},
data () {

View file

@ -23,8 +23,8 @@
/>
<cropper-selection
ref="cropperSelection"
initial-coverage="1"
aspect-ratio="1"
initial-coverage="0.9"
:aspect-ratio="aspectRatio"
movable
resizable
@change="onCropperSelectionChange"

View file

@ -158,12 +158,24 @@ const ProfileTab = {
}
reader.readAsDataURL(file)
},
displayUploadError (error) {
useInterfaceStore().pushGlobalNotice({
messageKey: 'upload.error.message',
messageArgs: [error.message],
level: 'error'
})
resetBackground () {
const confirmed = window.confirm(this.$t('settings.reset_background_confirm'))
if (confirmed) {
this.submitBackground('')
}
},
submitBackground (background) {
if (!this.backgroundPreview && background !== '') { return }
this.backgroundUploading = true
this.$store.state.api.backendInteractor.updateProfileImages({ background })
.then((data) => {
this.$store.commit('addNewUsers', [data])
this.$store.commit('setCurrentUser', data)
this.backgroundPreview = null
})
.catch(this.displayUploadError)
.finally(() => { this.backgroundUploading = false })
},
propsToNative (props) {
return propsToNative(props)

View file

@ -17,7 +17,8 @@ const UserAvatar = {
props: [
'user',
'compact',
'showActorTypeIndicator'
'showActorTypeIndicator',
'url'
],
data () {
return {

View file

@ -8,7 +8,7 @@
class="avatar"
:alt="user.screen_name_ui"
:title="user.screen_name_ui"
:src="imgSrc(user.profile_image_url_original)"
:src="url ? url : imgSrc(user.profile_image_url_original)"
:image-load-error="imageLoadError"
:class="{ '-compact': compact, '-better-shadow': betterShadow }"
/>

View file

@ -37,7 +37,9 @@ import {
faExpandAlt,
faBirthdayCake,
faSave,
faChevronRight
faChevronRight,
faChevronDown,
faClockRotateLeft
} from '@fortawesome/free-solid-svg-icons'
import { useMediaViewerStore } from '../../stores/media_viewer'
@ -53,7 +55,9 @@ library.add(
faTimes,
faExpandAlt,
faBirthdayCake,
faChevronRight
faChevronRight,
faChevronDown,
faClockRotateLeft
)
export default {
@ -98,8 +102,12 @@ export default {
// Editable stuff
newName: user.name_unescaped,
editingName: false,
newActorType: user.actor_type,
editImage: false,
newAvatar: '',
newAvatarFile: null,
newBanner: '',
newBannerFile: null,
newActorType: user.actor_type,
newBio: unescape(user.description),
editingBio: false,
newBirthday: user.birthday,
@ -133,7 +141,7 @@ export default {
return {
backgroundImage: [
'linear-gradient(to bottom, var(--profileTint), var(--profileTint))',
`url(${this.newCoverPhoto})`
`url(${this.bannerImgSrc})`
].join(', ')
}
},
@ -230,11 +238,25 @@ export default {
},
// Editable stuff
avatarImgSrc () {
const src = this.newAvatar
return (!src) ? this.defaultAvatar : src
},
bannerImgSrc () {
const src = this.newBanner
return (!src) ? this.defaultBanner : src
},
defaultAvatar () {
return this.$store.state.instance.server + this.$store.state.instance.defaultAvatar
if (this.isDefaultAvatar) {
return this.$store.state.instance.server + this.$store.state.instance.defaultAvatar
}
return this.user.profile_image_url
},
defaultBanner () {
return this.$store.state.instance.server + this.$store.state.instance.defaultBanner
if (this.isDefaultBanner) {
return this.$store.state.instance.server + this.$store.state.instance.defaultBanner
}
return this.user.cover_photo
},
isDefaultAvatar () {
const baseAvatar = this.$store.state.instance.defaultAvatar
@ -249,10 +271,6 @@ export default {
isDefaultBackground () {
return !(this.$store.state.users.currentUser.background_image)
},
avatarImgSrc () {
const src = this.$store.state.users.currentUser.profile_image_url_original
return (!src) ? this.defaultAvatar : src
},
fieldsLimits () {
return this.$store.state.instance.fieldsLimits
},
@ -358,26 +376,27 @@ export default {
}
},
submitImage ({ canvas, file }) {
const reqData = {}
if (this.editImage === 'avatar') {
if (canvas) {
return canvas.toBlob((data) => this.submitImage({ canvas: null, file: data }))
}
reqData.avatar = file
reqData.avatarName = file.name
} else {
reqData.banner = file
if (canvas) {
return canvas.toBlob((data) => this.submitImage({ canvas: null, file: data }))
}
return this.$store.state.api.backendInteractor.updateProfileImages(reqData)
.then((user) => {
this.$store.commit('addNewUsers', [user])
this.$store.commit('setCurrentUser', user)
this.editImage = false
})
.catch((error) => {
this.displayUploadError(error)
})
const reader = new window.FileReader()
reader.onload = (e) => {
const dataUrl = e.target.result
if (this.editImage === 'avatar') {
this.newAvatar = dataUrl
this.newAvatarFile = file
} else {
this.newBanner = dataUrl
this.newBannerFile = file
}
this.editImage = false
}
reader.readAsDataURL(file)
},
addField () {
if (this.newFields.length < this.maxFields) {
@ -395,6 +414,23 @@ export default {
cancelImageText () {
return
},
resetNews () {
const user = this.$store.state.users.currentUser
this.newName = user.name_unescaped
this.newAvatar = ''
this.newAvatarFile = null
this.newBanner = ''
this.newBannerFile = null
this.newActorType = user.actor_type
this.newBio = unescape(user.description)
this.newBirthday = user.birthday
this.newShowBirthday = user.show_birthday
this.newCoverPhoto = user.cover_photo
this.newFields = user.fields.map(field => ({ name: field.name, value: field.value }))
this.newLocked = user.locked
this.newShowRole = user.show_role
},
updateProfile () {
const params = {
note: this.newBio,
@ -403,10 +439,21 @@ export default {
// Backend notation.
display_name: this.newName,
fields_attributes: this.newFields.filter(el => el != null),
actor_type: this.actorType,
show_role: this.showRole,
show_role: !!this.showRole,
birthday: this.newBirthday || '',
show_birthday: this.showBirthday
show_birthday: !!this.showBirthday,
}
if (this.actorType) {
params.actor_type = this.actorType
}
if (this.newAvatar) {
params.avatar = this.newAvatarFile
}
if (this.newBanner) {
params.header = this.newBannerFile
}
if (this.emailLanguage) {
@ -416,11 +463,22 @@ export default {
this.$store.state.api.backendInteractor
.updateProfile({ params })
.then((user) => {
this.newFields.splice(user.fields.length)
this.newFields.splice(this.newFields.length)
merge(this.newFields, user.fields)
this.$store.commit('addNewUsers', [user])
this.$store.commit('setCurrentUser', user)
this.resetNews()
})
.catch((error) => {
this.displayUploadError(error)
})
},
displayUploadError (error) {
useInterfaceStore().pushGlobalNotice({
messageKey: 'upload.error.message',
messageArgs: [error.message],
level: 'error'
})
}
}
}

View file

@ -26,7 +26,7 @@
color: var(--lightText);
display: block;
line-height: 1.3;
padding: 0.6em;
padding: 0 0.6em;
margin: 0 0.6em;
img {
@ -38,8 +38,6 @@
}
.user-card-bio {
margin: 0.6em;
&, * {
line-height: 1.5;
}
@ -201,6 +199,7 @@
margin: -0.6em;
&.save-profile-button,
&.reset-profile-button,
&.edit-banner-button {
width: auto;
}
@ -280,6 +279,14 @@
.edit-button {
width: 3em;
text-align: center;
&:hover .icon {
color: var(--textFaint);
}
&:not(:hover) .icon {
color: var(--lightText);
}
}
.input,
@ -391,6 +398,7 @@
grid-template-columns: repeat(auto-fit, minmax(7.5em, 20%));
grid-gap: 0.6em;
max-width: 98vw;
margin-bottom: 0.6em;
.popover-trigger-button, .moderation-tools-button {
width: 100%;
@ -575,4 +583,25 @@
aspect-ratio: unset;
}
}
&.-banner {
.images-container {
grid-template-rows: 20em 5em 20em;
grid-template-columns: 1fr;
> * {
flex: 1 0 10em;
width: 100%;
aspect-ratio: 3;
}
}
.separator {
min-height: 1.1em;
font-size: 500%;
justify-self: center;
flex: 0 1 5em;
aspect-ratio: unset;
}
}
}

View file

@ -19,7 +19,10 @@
class="user-info-avatar -link"
@click="zoomAvatar"
>
<UserAvatar :user="user" />
<UserAvatar
:user="user"
:url="avatarImgSrc"
/>
<div class="user-info-avatar -link -overlay">
<FAIcon
class="fa-scale-110 fa-old-padding"
@ -73,6 +76,20 @@
:title="$t('user_card.change_banner')"
/>
</button>
<button
v-if="editable"
:disabled="somethingToSave"
class="btn button-unstyled reset-profile-button"
@click="resetNews"
>
{{ $t('settings.reset') }}
<FAIcon
fixed-width
class="icon"
icon="clock-rotate-left"
:title="$t('user_card.edit_profile')"
/>
</button>
<button
v-if="editable"
:disabled="somethingToSave"
@ -144,8 +161,8 @@
class="user-name"
>
<RichContent
:title="newName"
:html="newName"
:title="editable ? newName : user.name_unescaped"
:html="editable ? newName : user.name_unescaped"
:emoji="editable ? emoji : user.emoji"
/>
</router-link>
@ -314,6 +331,7 @@
</div>
</div>
</div>
<slot />
<div
v-if="!editable && loggedIn && isOtherUser && (hasNote || !hideBio) && !mergedConfig.userCardHidePersonalMarks"
class="personal-marks"
@ -368,6 +386,10 @@
@click="editingBio = !editingBio"
>
{{ $t('settings.toggle_edit') }}
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="pencil"
/>
</button>
</h4>
<template v-if="!editable || !editingBio">
@ -375,7 +397,7 @@
v-if="!hideBio"
class="user-card-bio"
:class="{ '-justify-left': mergedConfig.userCardLeftJustify }"
:html="user.description_html"
:html="editable ? newBio.replace(/\n/g, '<br>') : user.description_html"
:emoji="editable ? emoji : user.emoji"
:handle-links="true"
/>
@ -407,6 +429,10 @@
@click="editingFields = !editingFields"
>
{{ $t('settings.toggle_edit') }}
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="pencil"
/>
</button>
</h4>
<template v-if="!editable || !editingFields">
@ -415,7 +441,7 @@
class="user-profile-fields"
>
<dl
v-for="(field, index) in newFields"
v-for="(field, index) in (editable ? newFields : user.fields_html)"
:key="index"
class="user-profile-field"
>
@ -596,6 +622,7 @@
v-if="editImage"
:is-mute="true"
class="edit-image"
:class="{ '-banner': editImage === 'banner' }"
@backdrop-clicked="editImage = false"
>
<div class="panel">
@ -607,16 +634,17 @@
<div class="panel-body">
<div class="images-container">
<img
:src="editImage === 'avatar' ? user.profile_image_url_original : newBanner"
:src="editImage === 'avatar' ? avatarImgSrc : bannerImgSrc"
class="current-avatar"
/>
<FAIcon
class="separator"
icon="chevron-right"
:icon="editImage === 'avatar' ? 'chevron-right' : 'chevron-down'"
/>
<image-cropper
ref="cropper"
class="cropper"
:aspect-ratio="editImage === 'avatar' ? 1 : 3"
@submit="submitImage"
/>
</div>

View file

@ -393,6 +393,7 @@
"app_name": "App name",
"expert_mode": "Show advanced",
"save": "Save changes",
"reset": "Reset changes",
"security": "Security",
"toggle_edit": "Toggle edit",
"change_banner": "Change banner",

View file

@ -215,12 +215,26 @@ const updateProfileImages = ({ credentials, avatar = null, avatarName = null, ba
}
const updateProfile = ({ credentials, params }) => {
return promisedRequest({
url: MASTODON_PROFILE_UPDATE_URL,
const formData = new FormData();
for(const name in params) {
if (name === 'fields_attributes') {
params[name].forEach((param, i) => {
formData.append(name + `[${i}][name]`, param.name)
formData.append(name + `[${i}][value]`, param.value)
})
} else {
formData.append(name, params[name]);
}
}
return fetch(MASTODON_PROFILE_UPDATE_URL, {
headers: authHeaders(credentials),
method: 'PATCH',
payload: params,
credentials
}).then((data) => parseUser(data))
body: formData
})
.then((data) => data.json())
.then((data) => parseUser(data))
}
// Params needed: