avatar upload works

This commit is contained in:
Henry Jameson 2025-08-04 03:35:09 +03:00
commit b305748a92
10 changed files with 239 additions and 207 deletions

View file

@ -782,12 +782,6 @@ option {
color: var(--text); color: var(--text);
} }
.visibility-notice {
padding: 0.5em;
border: 1px solid var(--textFaint);
border-radius: var(--roundness);
}
.notice-dismissible { .notice-dismissible {
padding-right: 4rem; padding-right: 4rem;
position: relative; position: relative;

View file

@ -10,14 +10,6 @@ library.add(
const ImageCropper = { const ImageCropper = {
props: { props: {
trigger: {
type: [String, window.Element],
required: true
},
submitHandler: {
type: Function,
required: true
},
mimes: { mimes: {
type: String, type: String,
default: 'image/png, image/gif, image/jpeg, image/bmp, image/x-icon' default: 'image/png, image/gif, image/jpeg, image/bmp, image/x-icon'
@ -39,17 +31,7 @@ const ImageCropper = {
submitting: false submitting: false
} }
}, },
computed: { emits: ['submit'],
saveText () {
return this.saveButtonLabel || this.$t('image_cropper.save')
},
saveWithoutCroppingText () {
return this.saveWithoutCroppingButtonlabel || this.$t('image_cropper.save_without_cropping')
},
cancelText () {
return this.cancelButtonLabel || this.$t('image_cropper.cancel')
}
},
methods: { methods: {
destroy () { destroy () {
this.$refs.input.value = '' this.$refs.input.value = ''
@ -65,20 +47,15 @@ const ImageCropper = {
} else { } else {
cropperPromise = Promise.resolve() cropperPromise = Promise.resolve()
} }
cropperPromise.then(canvas => { cropperPromise.then(canvas => {
this.submitHandler(canvas, this.file) this.$emit('submit', { canvas, file: this.file })
.then(() => this.destroy()) this.submitting = false
.finally(() => {
this.submitting = false
})
}) })
}, },
pickImage () { pickImage () {
this.$refs.input.click() this.$refs.input.click()
}, },
getTriggerDOM () {
return typeof this.trigger === 'object' ? this.trigger : document.querySelector(this.trigger)
},
readFile () { readFile () {
const fileInput = this.$refs.input const fileInput = this.$refs.input
if (fileInput.files != null && fileInput.files[0] != null) { if (fileInput.files != null && fileInput.files[0] != null) {
@ -117,23 +94,11 @@ const ImageCropper = {
} }
}, },
mounted () { mounted () {
// listen for click event on trigger
const trigger = this.getTriggerDOM()
if (!trigger) {
this.$emit('error', 'No image make trigger found.', 'user')
} else {
trigger.addEventListener('click', this.pickImage)
}
// listen for input file changes // listen for input file changes
const fileInput = this.$refs.input const fileInput = this.$refs.input
fileInput.addEventListener('change', this.readFile) fileInput.addEventListener('change', this.readFile)
}, },
beforeUnmount: function () { beforeUnmount: function () {
// remove the event listeners
const trigger = this.getTriggerDOM()
if (trigger) {
trigger.removeEventListener('click', this.pickImage)
}
const fileInput = this.$refs.input const fileInput = this.$refs.input
fileInput.removeEventListener('change', this.readFile) fileInput.removeEventListener('change', this.readFile)
} }

View file

@ -1,13 +1,14 @@
<template> <template>
<div class="image-cropper"> <div class="image-cropper">
<div v-if="dataUrl"> <div class="image">
<cropper-canvas <cropper-canvas
ref="cropperCanvas" ref="cropperCanvas"
background background
class="image-cropper-canvas" class="image-cropper-canvas"
height="25em" height="100%"
> >
<cropper-image <cropper-image
v-if="dataUrl"
ref="cropperImage" ref="cropperImage"
:src="dataUrl" :src="dataUrl"
alt="Picture" alt="Picture"
@ -47,41 +48,13 @@
<cropper-handle action="sw-resize" /> <cropper-handle action="sw-resize" />
</cropper-selection> </cropper-selection>
</cropper-canvas> </cropper-canvas>
<div class="image-cropper-buttons-wrapper">
<button
class="button-default btn"
type="button"
:disabled="submitting"
@click="submit()"
v-text="saveText"
/>
<button
class="button-default btn"
type="button"
:disabled="submitting"
@click="destroy"
v-text="cancelText"
/>
<button
class="button-default btn"
type="button"
:disabled="submitting"
@click="submit(false)"
v-text="saveWithoutCroppingText"
/>
<FAIcon
v-if="submitting"
spin
icon="circle-notch"
/>
</div>
</div> </div>
<input <input
ref="input" ref="input"
type="file" type="file"
class="input image-cropper-img-input" class="input image-cropper-img-input"
:accept="mimes" :accept="mimes"
> />
</div> </div>
</template> </template>
@ -89,13 +62,15 @@
<style lang="scss"> <style lang="scss">
.image-cropper { .image-cropper {
&-img-input { display: flex;
display: none; flex-direction: column;
&-canvas, .image {
height: 100%;
} }
&-canvas { & &-img-input {
height: 25em; display: none;
width: 25em;
} }
&-buttons-wrapper { &-buttons-wrapper {

View file

@ -42,7 +42,6 @@ const ProfileTab = {
role: this.$store.state.users.currentUser.role, role: this.$store.state.users.currentUser.role,
bot: this.$store.state.users.currentUser.bot, bot: this.$store.state.users.currentUser.bot,
actorType: this.$store.state.users.currentUser.actor_type, actorType: this.$store.state.users.currentUser.actor_type,
pickAvatarBtnVisible: true,
bannerUploading: false, bannerUploading: false,
backgroundUploading: false, backgroundUploading: false,
banner: null, banner: null,
@ -88,29 +87,6 @@ const ProfileTab = {
userSuggestor () { userSuggestor () {
return suggestor({ store: this.$store }) return suggestor({ store: this.$store })
}, },
defaultAvatar () {
return this.$store.state.instance.server + this.$store.state.instance.defaultAvatar
},
defaultBanner () {
return this.$store.state.instance.server + this.$store.state.instance.defaultBanner
},
isDefaultAvatar () {
const baseAvatar = this.$store.state.instance.defaultAvatar
return !(this.$store.state.users.currentUser.profile_image_url) ||
this.$store.state.users.currentUser.profile_image_url.includes(baseAvatar)
},
isDefaultBanner () {
const baseBanner = this.$store.state.instance.defaultBanner
return !(this.$store.state.users.currentUser.cover_photo) ||
this.$store.state.users.currentUser.cover_photo.includes(baseBanner)
},
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
},
bannerImgSrc () { bannerImgSrc () {
const src = this.$store.state.users.currentUser.cover_photo const src = this.$store.state.users.currentUser.cover_photo
return (!src) ? this.defaultBanner : src return (!src) ? this.defaultBanner : src
@ -153,16 +129,6 @@ const ProfileTab = {
changeVis (visibility) { changeVis (visibility) {
this.newDefaultScope = visibility this.newDefaultScope = visibility
}, },
addField () {
if (this.newFields.length < this.maxFields) {
this.newFields.push({ name: '', value: '' })
return true
}
return false
},
deleteField (index) {
this.newFields.splice(index, 1)
},
uploadFile (slot, e) { uploadFile (slot, e) {
const file = e.target.files[0] const file = e.target.files[0]
if (!file) { return } if (!file) { return }
@ -192,73 +158,6 @@ const ProfileTab = {
} }
reader.readAsDataURL(file) reader.readAsDataURL(file)
}, },
resetAvatar () {
const confirmed = window.confirm(this.$t('settings.reset_avatar_confirm'))
if (confirmed) {
this.submitAvatar(undefined, '')
}
},
resetBanner () {
const confirmed = window.confirm(this.$t('settings.reset_banner_confirm'))
if (confirmed) {
this.submitBanner('')
}
},
resetBackground () {
const confirmed = window.confirm(this.$t('settings.reset_background_confirm'))
if (confirmed) {
this.submitBackground('')
}
},
submitAvatar (canvas, file) {
const that = this
return new Promise((resolve, reject) => {
function updateAvatar (avatar, avatarName) {
that.$store.state.api.backendInteractor.updateProfileImages({ avatar, avatarName })
.then((user) => {
that.$store.commit('addNewUsers', [user])
that.$store.commit('setCurrentUser', user)
resolve()
})
.catch((error) => {
that.displayUploadError(error)
reject(error)
})
}
if (canvas) {
canvas.toBlob((data) => updateAvatar(data, file.name), file.type)
} else {
updateAvatar(file, file.name)
}
})
},
submitBanner (banner) {
if (!this.bannerPreview && banner !== '') { return }
this.bannerUploading = true
this.$store.state.api.backendInteractor.updateProfileImages({ banner })
.then((user) => {
this.$store.commit('addNewUsers', [user])
this.$store.commit('setCurrentUser', user)
this.bannerPreview = null
})
.catch(this.displayUploadError)
.finally(() => { this.bannerUploading = false })
},
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 })
},
displayUploadError (error) { displayUploadError (error) {
useInterfaceStore().pushGlobalNotice({ useInterfaceStore().pushGlobalNotice({
messageKey: 'upload.error.message', messageKey: 'upload.error.message',

View file

@ -3,10 +3,6 @@
margin: 0; margin: 0;
} }
.visibility-tray {
padding-top: 5px;
}
input[type="file"] { input[type="file"] {
padding: 5px; padding: 5px;
height: auto; height: auto;

View file

@ -7,6 +7,7 @@
:switcher="false" :switcher="false"
rounded="top" rounded="top"
/> />
<p>{{ $t('settings.name') }}</p>
<p v-if="role === 'admin' || role === 'moderator'"> <p v-if="role === 'admin' || role === 'moderator'">
<Checkbox v-model="showRole"> <Checkbox v-model="showRole">
<template v-if="role === 'admin'"> <template v-if="role === 'admin'">

View file

@ -15,6 +15,8 @@ import RichContent from 'src/components/rich_content/rich_content.jsx'
import UserTimedFilterModal from 'src/components/user_timed_filter_modal/user_timed_filter_modal.vue' import UserTimedFilterModal from 'src/components/user_timed_filter_modal/user_timed_filter_modal.vue'
import Checkbox from 'src/components/checkbox/checkbox.vue' import Checkbox from 'src/components/checkbox/checkbox.vue'
import EmojiInput from 'src/components/emoji_input/emoji_input.vue' import EmojiInput from 'src/components/emoji_input/emoji_input.vue'
import Modal from 'src/components/modal/modal.vue'
import ImageCropper from 'src/components/image_cropper/image_cropper.vue'
import localeService from 'src/services/locale/locale.service.js' import localeService from 'src/services/locale/locale.service.js'
import suggestor from 'src/components/emoji_input/suggestor.js' import suggestor from 'src/components/emoji_input/suggestor.js'
@ -34,7 +36,8 @@ import {
faTimes, faTimes,
faExpandAlt, faExpandAlt,
faBirthdayCake, faBirthdayCake,
faSave faSave,
faChevronRight
} from '@fortawesome/free-solid-svg-icons' } from '@fortawesome/free-solid-svg-icons'
import { useMediaViewerStore } from '../../stores/media_viewer' import { useMediaViewerStore } from '../../stores/media_viewer'
@ -49,7 +52,8 @@ library.add(
faEdit, faEdit,
faTimes, faTimes,
faExpandAlt, faExpandAlt,
faBirthdayCake faBirthdayCake,
faChevronRight
) )
export default { export default {
@ -66,6 +70,7 @@ export default {
'hasNoteEditor' 'hasNoteEditor'
], ],
components: { components: {
Modal,
UserAvatar, UserAvatar,
Checkbox, Checkbox,
RemoteFollow, RemoteFollow,
@ -79,7 +84,8 @@ export default {
UserNote, UserNote,
UserTimedFilterModal, UserTimedFilterModal,
ColorInput, ColorInput,
EmojiInput EmojiInput,
ImageCropper
}, },
data () { data () {
const user = this.$store.state.users.currentUser const user = this.$store.state.users.currentUser
@ -93,6 +99,7 @@ export default {
newName: user.name_unescaped, newName: user.name_unescaped,
editingName: false, editingName: false,
newActorType: user.actor_type, newActorType: user.actor_type,
editImage: false,
newBio: unescape(user.description), newBio: unescape(user.description),
editingBio: false, editingBio: false,
newBirthday: user.birthday, newBirthday: user.birthday,
@ -223,6 +230,29 @@ export default {
}, },
// Editable stuff // Editable stuff
defaultAvatar () {
return this.$store.state.instance.server + this.$store.state.instance.defaultAvatar
},
defaultBanner () {
return this.$store.state.instance.server + this.$store.state.instance.defaultBanner
},
isDefaultAvatar () {
const baseAvatar = this.$store.state.instance.defaultAvatar
return !(this.$store.state.users.currentUser.profile_image_url) ||
this.$store.state.users.currentUser.profile_image_url.includes(baseAvatar)
},
isDefaultBanner () {
const baseBanner = this.$store.state.instance.defaultBanner
return !(this.$store.state.users.currentUser.cover_photo) ||
this.$store.state.users.currentUser.cover_photo.includes(baseBanner)
},
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 () { fieldsLimits () {
return this.$store.state.instance.fieldsLimits return this.$store.state.instance.fieldsLimits
}, },
@ -303,6 +333,52 @@ export default {
}, },
// Editable stuff // Editable stuff
changeAvatar () {
this.editImage = 'avatar'
},
changeBanner () {
this.editImage = 'banner'
},
resetAvatar () {
const confirmed = window.confirm(this.$t('settings.reset_avatar_confirm'))
if (confirmed) {
this.submitAvatar(undefined, '')
}
},
resetBanner () {
const confirmed = window.confirm(this.$t('settings.reset_banner_confirm'))
if (confirmed) {
this.submitBanner('')
}
},
resetBackground () {
const confirmed = window.confirm(this.$t('settings.reset_background_confirm'))
if (confirmed) {
this.submitBackground('')
}
},
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
}
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)
})
},
addField () { addField () {
if (this.newFields.length < this.maxFields) { if (this.newFields.length < this.maxFields) {
this.newFields.push({ name: '', value: '' }) this.newFields.push({ name: '', value: '' })
@ -316,6 +392,9 @@ export default {
propsToNative (props) { propsToNative (props) {
return propsToNative(props) return propsToNative(props)
}, },
cancelImageText () {
return
},
updateProfile () { updateProfile () {
const params = { const params = {
note: this.newBio, note: this.newBio,

View file

@ -21,6 +21,22 @@
padding-bottom: 0; padding-bottom: 0;
} }
&-bio {
text-align: center;
color: var(--lightText);
display: block;
line-height: 1.3;
padding: 0.6em;
margin: 0 0.6em;
img {
object-fit: contain;
vertical-align: middle;
max-width: 100%;
max-height: 400px;
}
}
.user-card-bio { .user-card-bio {
margin: 0.6em; margin: 0.6em;
@ -101,22 +117,6 @@
z-index: -2; z-index: -2;
} }
&-bio {
text-align: center;
color: var(--lightText);
display: block;
line-height: 1.3;
padding: 0.6em;
margin: 0 0.6em;
img {
object-fit: contain;
vertical-align: middle;
max-width: 100%;
max-height: 400px;
}
}
&.-rounded-t { &.-rounded-t {
border-top-left-radius: var(--roundness); border-top-left-radius: var(--roundness);
border-top-right-radius: var(--roundness); border-top-right-radius: var(--roundness);
@ -535,3 +535,44 @@
} }
} }
} }
.edit-image {
.panel-body {
text-align: center;
}
.current-avatar {
object-fit: contain;
}
.images-container {
display: grid;
margin: 1em;
grid-template-columns: 1fr 5em 1fr;
grid-template-rows: 20em;
gap: 0.5em;
.new-image {
display: flex;
flex-direction: column;
}
.cropper {
flex: 1;
}
> * {
flex: 1 0 10em;
max-height: 100%;
aspect-ratio: 1;
}
.separator {
min-width: 1.1em;
font-size: 500%;
align-self: center;
flex: 0 1 5em;
aspect-ratio: unset;
}
}
}

View file

@ -31,7 +31,7 @@
v-else-if="editable" v-else-if="editable"
class="user-info-avatar button-unstyled -link" class="user-info-avatar button-unstyled -link"
:class="{ '-editable': editable }" :class="{ '-editable': editable }"
@click="editAvatar" @click="changeAvatar"
> >
<UserAvatar :user="user" /> <UserAvatar :user="user" />
<div class="user-info-avatar -link -overlay"> <div class="user-info-avatar -link -overlay">
@ -63,7 +63,7 @@
v-if="editable" v-if="editable"
:disabled="newName && newName.length === 0" :disabled="newName && newName.length === 0"
class="btn button-unstyled edit-banner-button" class="btn button-unstyled edit-banner-button"
@click="updateProfile" @click="changeBanner"
> >
{{ $t('settings.change_banner') }} {{ $t('settings.change_banner') }}
<FAIcon <FAIcon
@ -591,6 +591,85 @@
:is-mute="true" :is-mute="true"
/> />
</teleport> </teleport>
<teleport to="#modal">
<Modal
v-if="editImage"
:is-mute="true"
class="edit-image"
@backdrop-clicked="editImage = false"
>
<div class="panel">
<div class="panel-heading">
<h1 class="title">
{{ editImage === 'avatar' ? $t('settings.change_avatar') : $t('settings.change_banner') }}
</h1>
</div>
<div class="panel-body">
<div class="images-container">
<img
:src="editImage === 'avatar' ? user.profile_image_url_original : newBanner"
class="current-avatar"
/>
<FAIcon
class="separator"
icon="chevron-right"
/>
<image-cropper
ref="cropper"
class="cropper"
@submit="submitImage"
/>
</div>
<button
id="pick-image"
class="button-default btn"
type="button"
@click="() => this.$refs.cropper.pickImage()"
>
{{ $t('settings.upload_picture') }}
</button>
<p class="visibility-notice">
{{ $t('settings.avatar_size_instruction') }}
</p>
<button
:title="editImage === 'avatar' ? $t('settings.reset_avatar') : $t('settings.reset_banner')"
class="button-unstyled reset-button"
@click="resetImage"
>
</button>
<div class="panel-footer">
<div/>
<button
class="button-default btn"
type="button"
@click="destroy"
>
{{ this.$t('image_cropper.cancel') }}
</button>
<button
class="button-default btn"
type="button"
@click="this.$refs.cropper.submit(true)"
>
{{ $t('image_cropper.save') }}
</button>
<button
class="button-default btn"
type="button"
@click="this.$refs.cropper.submit(false)"
>
{{ $t('image_cropper.save_without_cropping') }}
</button>
<FAIcon
v-if="submitting"
spin
icon="circle-notch"
/>
</div>
</div>
</div>
</Modal>
</teleport>
</div> </div>
</template> </template>

View file

@ -396,6 +396,7 @@
"security": "Security", "security": "Security",
"toggle_edit": "Toggle edit", "toggle_edit": "Toggle edit",
"change_banner": "Change banner", "change_banner": "Change banner",
"change_avatar": "Change avatar",
"setting_changed": "Setting is different from default", "setting_changed": "Setting is different from default",
"setting_server_side": "This setting is tied to your profile and affects all sessions and clients", "setting_server_side": "This setting is tied to your profile and affects all sessions and clients",
"enter_current_password_to_confirm": "Enter your current password to confirm your identity", "enter_current_password_to_confirm": "Enter your current password to confirm your identity",
@ -726,6 +727,7 @@
"set_new_profile_background": "Set new profile background", "set_new_profile_background": "Set new profile background",
"set_new_profile_banner": "Set new profile banner", "set_new_profile_banner": "Set new profile banner",
"reset_avatar": "Reset avatar", "reset_avatar": "Reset avatar",
"reset_banner": "Reset banner",
"reset_profile_background": "Reset profile background", "reset_profile_background": "Reset profile background",
"reset_profile_banner": "Reset profile banner", "reset_profile_banner": "Reset profile banner",
"reset_avatar_confirm": "Do you really want to reset the avatar?", "reset_avatar_confirm": "Do you really want to reset the avatar?",
@ -778,6 +780,7 @@
"tooltipRadius": "Tooltips/alerts", "tooltipRadius": "Tooltips/alerts",
"type_domains_to_mute": "Search domains to mute", "type_domains_to_mute": "Search domains to mute",
"upload_a_photo": "Upload a photo", "upload_a_photo": "Upload a photo",
"upload_picture": "Upload picture",
"user_settings": "User Settings", "user_settings": "User Settings",
"values": { "values": {
"false": "no", "false": "no",