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: { cancelButtonLabel: {
type: String type: String
},
aspectRatio: {
type: Number
} }
}, },
data () { data () {

View file

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

View file

@ -158,12 +158,24 @@ const ProfileTab = {
} }
reader.readAsDataURL(file) reader.readAsDataURL(file)
}, },
displayUploadError (error) { resetBackground () {
useInterfaceStore().pushGlobalNotice({ const confirmed = window.confirm(this.$t('settings.reset_background_confirm'))
messageKey: 'upload.error.message', if (confirmed) {
messageArgs: [error.message], this.submitBackground('')
level: 'error' }
},
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) { propsToNative (props) {
return propsToNative(props) return propsToNative(props)

View file

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

View file

@ -8,7 +8,7 @@
class="avatar" class="avatar"
:alt="user.screen_name_ui" :alt="user.screen_name_ui"
:title="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" :image-load-error="imageLoadError"
:class="{ '-compact': compact, '-better-shadow': betterShadow }" :class="{ '-compact': compact, '-better-shadow': betterShadow }"
/> />

View file

@ -37,7 +37,9 @@ import {
faExpandAlt, faExpandAlt,
faBirthdayCake, faBirthdayCake,
faSave, faSave,
faChevronRight faChevronRight,
faChevronDown,
faClockRotateLeft
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
import { useMediaViewerStore } from '../../stores/media_viewer' import { useMediaViewerStore } from '../../stores/media_viewer'
@ -53,7 +55,9 @@ library.add(
faTimes, faTimes,
faExpandAlt, faExpandAlt,
faBirthdayCake, faBirthdayCake,
faChevronRight faChevronRight,
faChevronDown,
faClockRotateLeft
) )
export default { export default {
@ -98,8 +102,12 @@ export default {
// Editable stuff // Editable stuff
newName: user.name_unescaped, newName: user.name_unescaped,
editingName: false, editingName: false,
newActorType: user.actor_type,
editImage: false, editImage: false,
newAvatar: '',
newAvatarFile: null,
newBanner: '',
newBannerFile: null,
newActorType: user.actor_type,
newBio: unescape(user.description), newBio: unescape(user.description),
editingBio: false, editingBio: false,
newBirthday: user.birthday, newBirthday: user.birthday,
@ -133,7 +141,7 @@ export default {
return { return {
backgroundImage: [ backgroundImage: [
'linear-gradient(to bottom, var(--profileTint), var(--profileTint))', 'linear-gradient(to bottom, var(--profileTint), var(--profileTint))',
`url(${this.newCoverPhoto})` `url(${this.bannerImgSrc})`
].join(', ') ].join(', ')
} }
}, },
@ -230,11 +238,25 @@ export default {
}, },
// Editable stuff // Editable stuff
avatarImgSrc () {
const src = this.newAvatar
return (!src) ? this.defaultAvatar : src
},
bannerImgSrc () {
const src = this.newBanner
return (!src) ? this.defaultBanner : src
},
defaultAvatar () { defaultAvatar () {
if (this.isDefaultAvatar) {
return this.$store.state.instance.server + this.$store.state.instance.defaultAvatar return this.$store.state.instance.server + this.$store.state.instance.defaultAvatar
}
return this.user.profile_image_url
}, },
defaultBanner () { defaultBanner () {
if (this.isDefaultBanner) {
return this.$store.state.instance.server + this.$store.state.instance.defaultBanner return this.$store.state.instance.server + this.$store.state.instance.defaultBanner
}
return this.user.cover_photo
}, },
isDefaultAvatar () { isDefaultAvatar () {
const baseAvatar = this.$store.state.instance.defaultAvatar const baseAvatar = this.$store.state.instance.defaultAvatar
@ -249,10 +271,6 @@ export default {
isDefaultBackground () { isDefaultBackground () {
return !(this.$store.state.users.currentUser.background_image) 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 () { fieldsLimits () {
return this.$store.state.instance.fieldsLimits return this.$store.state.instance.fieldsLimits
}, },
@ -358,26 +376,27 @@ export default {
} }
}, },
submitImage ({ canvas, file }) { submitImage ({ canvas, file }) {
const reqData = {}
if (this.editImage === 'avatar') {
if (canvas) { if (canvas) {
return canvas.toBlob((data) => this.submitImage({ canvas: null, file: data })) return canvas.toBlob((data) => this.submitImage({ canvas: null, file: data }))
} }
reqData.avatar = file
reqData.avatarName = file.name const reader = new window.FileReader()
reader.onload = (e) => {
const dataUrl = e.target.result
if (this.editImage === 'avatar') {
this.newAvatar = dataUrl
this.newAvatarFile = file
} else { } else {
reqData.banner = file this.newBanner = dataUrl
this.newBannerFile = file
} }
return this.$store.state.api.backendInteractor.updateProfileImages(reqData)
.then((user) => {
this.$store.commit('addNewUsers', [user])
this.$store.commit('setCurrentUser', user)
this.editImage = false this.editImage = false
}) }
.catch((error) => {
this.displayUploadError(error) reader.readAsDataURL(file)
})
}, },
addField () { addField () {
if (this.newFields.length < this.maxFields) { if (this.newFields.length < this.maxFields) {
@ -395,6 +414,23 @@ export default {
cancelImageText () { cancelImageText () {
return 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 () { updateProfile () {
const params = { const params = {
note: this.newBio, note: this.newBio,
@ -403,10 +439,21 @@ export default {
// Backend notation. // Backend notation.
display_name: this.newName, display_name: this.newName,
fields_attributes: this.newFields.filter(el => el != null), fields_attributes: this.newFields.filter(el => el != null),
actor_type: this.actorType, show_role: !!this.showRole,
show_role: this.showRole,
birthday: this.newBirthday || '', 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) { if (this.emailLanguage) {
@ -416,11 +463,22 @@ export default {
this.$store.state.api.backendInteractor this.$store.state.api.backendInteractor
.updateProfile({ params }) .updateProfile({ params })
.then((user) => { .then((user) => {
this.newFields.splice(user.fields.length) this.newFields.splice(this.newFields.length)
merge(this.newFields, user.fields) merge(this.newFields, user.fields)
this.$store.commit('addNewUsers', [user]) this.$store.commit('addNewUsers', [user])
this.$store.commit('setCurrentUser', 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); color: var(--lightText);
display: block; display: block;
line-height: 1.3; line-height: 1.3;
padding: 0.6em; padding: 0 0.6em;
margin: 0 0.6em; margin: 0 0.6em;
img { img {
@ -38,8 +38,6 @@
} }
.user-card-bio { .user-card-bio {
margin: 0.6em;
&, * { &, * {
line-height: 1.5; line-height: 1.5;
} }
@ -201,6 +199,7 @@
margin: -0.6em; margin: -0.6em;
&.save-profile-button, &.save-profile-button,
&.reset-profile-button,
&.edit-banner-button { &.edit-banner-button {
width: auto; width: auto;
} }
@ -280,6 +279,14 @@
.edit-button { .edit-button {
width: 3em; width: 3em;
text-align: center; text-align: center;
&:hover .icon {
color: var(--textFaint);
}
&:not(:hover) .icon {
color: var(--lightText);
}
} }
.input, .input,
@ -391,6 +398,7 @@
grid-template-columns: repeat(auto-fit, minmax(7.5em, 20%)); grid-template-columns: repeat(auto-fit, minmax(7.5em, 20%));
grid-gap: 0.6em; grid-gap: 0.6em;
max-width: 98vw; max-width: 98vw;
margin-bottom: 0.6em;
.popover-trigger-button, .moderation-tools-button { .popover-trigger-button, .moderation-tools-button {
width: 100%; width: 100%;
@ -575,4 +583,25 @@
aspect-ratio: unset; 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" class="user-info-avatar -link"
@click="zoomAvatar" @click="zoomAvatar"
> >
<UserAvatar :user="user" /> <UserAvatar
:user="user"
:url="avatarImgSrc"
/>
<div class="user-info-avatar -link -overlay"> <div class="user-info-avatar -link -overlay">
<FAIcon <FAIcon
class="fa-scale-110 fa-old-padding" class="fa-scale-110 fa-old-padding"
@ -73,6 +76,20 @@
:title="$t('user_card.change_banner')" :title="$t('user_card.change_banner')"
/> />
</button> </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 <button
v-if="editable" v-if="editable"
:disabled="somethingToSave" :disabled="somethingToSave"
@ -144,8 +161,8 @@
class="user-name" class="user-name"
> >
<RichContent <RichContent
:title="newName" :title="editable ? newName : user.name_unescaped"
:html="newName" :html="editable ? newName : user.name_unescaped"
:emoji="editable ? emoji : user.emoji" :emoji="editable ? emoji : user.emoji"
/> />
</router-link> </router-link>
@ -314,6 +331,7 @@
</div> </div>
</div> </div>
</div> </div>
<slot />
<div <div
v-if="!editable && loggedIn && isOtherUser && (hasNote || !hideBio) && !mergedConfig.userCardHidePersonalMarks" v-if="!editable && loggedIn && isOtherUser && (hasNote || !hideBio) && !mergedConfig.userCardHidePersonalMarks"
class="personal-marks" class="personal-marks"
@ -368,6 +386,10 @@
@click="editingBio = !editingBio" @click="editingBio = !editingBio"
> >
{{ $t('settings.toggle_edit') }} {{ $t('settings.toggle_edit') }}
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="pencil"
/>
</button> </button>
</h4> </h4>
<template v-if="!editable || !editingBio"> <template v-if="!editable || !editingBio">
@ -375,7 +397,7 @@
v-if="!hideBio" v-if="!hideBio"
class="user-card-bio" class="user-card-bio"
:class="{ '-justify-left': mergedConfig.userCardLeftJustify }" :class="{ '-justify-left': mergedConfig.userCardLeftJustify }"
:html="user.description_html" :html="editable ? newBio.replace(/\n/g, '<br>') : user.description_html"
:emoji="editable ? emoji : user.emoji" :emoji="editable ? emoji : user.emoji"
:handle-links="true" :handle-links="true"
/> />
@ -407,6 +429,10 @@
@click="editingFields = !editingFields" @click="editingFields = !editingFields"
> >
{{ $t('settings.toggle_edit') }} {{ $t('settings.toggle_edit') }}
<FAIcon
class="fa-scale-110 fa-old-padding"
icon="pencil"
/>
</button> </button>
</h4> </h4>
<template v-if="!editable || !editingFields"> <template v-if="!editable || !editingFields">
@ -415,7 +441,7 @@
class="user-profile-fields" class="user-profile-fields"
> >
<dl <dl
v-for="(field, index) in newFields" v-for="(field, index) in (editable ? newFields : user.fields_html)"
:key="index" :key="index"
class="user-profile-field" class="user-profile-field"
> >
@ -596,6 +622,7 @@
v-if="editImage" v-if="editImage"
:is-mute="true" :is-mute="true"
class="edit-image" class="edit-image"
:class="{ '-banner': editImage === 'banner' }"
@backdrop-clicked="editImage = false" @backdrop-clicked="editImage = false"
> >
<div class="panel"> <div class="panel">
@ -607,16 +634,17 @@
<div class="panel-body"> <div class="panel-body">
<div class="images-container"> <div class="images-container">
<img <img
:src="editImage === 'avatar' ? user.profile_image_url_original : newBanner" :src="editImage === 'avatar' ? avatarImgSrc : bannerImgSrc"
class="current-avatar" class="current-avatar"
/> />
<FAIcon <FAIcon
class="separator" class="separator"
icon="chevron-right" :icon="editImage === 'avatar' ? 'chevron-right' : 'chevron-down'"
/> />
<image-cropper <image-cropper
ref="cropper" ref="cropper"
class="cropper" class="cropper"
:aspect-ratio="editImage === 'avatar' ? 1 : 3"
@submit="submitImage" @submit="submitImage"
/> />
</div> </div>

View file

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

View file

@ -215,12 +215,26 @@ const updateProfileImages = ({ credentials, avatar = null, avatarName = null, ba
} }
const updateProfile = ({ credentials, params }) => { const updateProfile = ({ credentials, params }) => {
return promisedRequest({ const formData = new FormData();
url: MASTODON_PROFILE_UPDATE_URL,
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', method: 'PATCH',
payload: params, body: formData
credentials })
}).then((data) => parseUser(data)) .then((data) => data.json())
.then((data) => parseUser(data))
} }
// Params needed: // Params needed: