@@ -21,7 +21,8 @@ import api from 'lib/api'
2121import Identicon from ' components/Identicon'
2222import UploadButton from ' components/UploadButton'
2323
24- const MAX_AVATAR_SIZE = 128
24+ const MIN_AVATAR_SIZE = 128
25+ const UPLOAD_AVATAR_SIZE = 256
2526const MAX_FILE_SIZE_MB = 10
2627const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024
2728const ALLOWED_FORMATS = [' image/png' , ' image/jpg' , ' image/jpeg' ]
@@ -42,6 +43,7 @@ const avatarImage = ref(null)
4243const fileError = ref (null )
4344const changedImage = ref (false )
4445const cropperRef = ref (null )
46+ const selectedFileSize = ref (null )
4547
4648// Derived
4749const identiconUser = computed (() => ({
@@ -55,16 +57,19 @@ const identiconUser = computed(() => ({
5557function changeIdenticon () {
5658 fileError .value = null
5759 avatarImage .value = null
60+ selectedFileSize .value = null
5861 identiconValue .value = uuid ()
5962 emit (' blockSave' , false )
6063}
6164
6265function fileSelected (event ) {
6366 fileError .value = null
6467 avatarImage .value = null
68+ selectedFileSize .value = null
6569 emit (' blockSave' , false )
6670 if (event .target .files .length !== 1 ) return
6771 const avatarFile = event .target .files [0 ]
72+ selectedFileSize .value = avatarFile .size
6873
6974 // Validate file size (10MB max)
7075 if (avatarFile .size > MAX_FILE_SIZE_BYTES ) {
@@ -89,7 +94,7 @@ function fileSelected(event) {
8994 if (readerEvent .target .readyState !== FileReader .DONE ) return
9095 const img = new Image ()
9196 img .onload = () => {
92- if (img .width < 128 || img .height < 128 ) {
97+ if (img .width < MIN_AVATAR_SIZE || img .height < MIN_AVATAR_SIZE ) {
9398 fileError .value = proxy .$t (' profile/ChangeAvatar:error:image-too-small' )
9499 emit (' blockSave' , true )
95100 } else {
@@ -105,29 +110,91 @@ function fileSelected(event) {
105110
106111function pixelsRestrictions ({ minWidth, minHeight, maxWidth, maxHeight }) {
107112 return {
108- minWidth: Math .max (128 , minWidth),
109- minHeight: Math .max (128 , minHeight),
113+ minWidth: Math .max (MIN_AVATAR_SIZE , minWidth),
114+ minHeight: Math .max (MIN_AVATAR_SIZE , minHeight),
110115 maxWidth,
111116 maxHeight,
112117 }
113118}
114119
115- function update () {
116- return new Promise ((resolve ) => {
117- const { canvas } = cropperRef .value ? .getResult () || {}
118- if (! canvas) {
119- emit (' update:modelValue' , { identicon: identiconValue .value })
120- return resolve ()
121- }
122- if (! changedImage .value ) return resolve ()
120+ async function update () {
121+ const { canvas } = cropperRef .value ? .getResult () || {}
122+ if (! canvas) {
123+ emit (' update:modelValue' , { identicon: identiconValue .value })
124+ return
125+ }
126+ if (! changedImage .value ) return
127+
128+ const processed = await createAvatarBlob (canvas)
129+ if (! processed) {
130+ fileError .value = proxy .$t (' profile/ChangeAvatar:error:process-failed' )
131+ emit (' blockSave' , true )
132+ return
133+ }
134+ const { blob: resizedBlob , dimension } = processed
135+ if (ENV_DEVELOPMENT ) {
136+ console .info (' [avatar-upload] original size:' , selectedFileSize .value || 0 , ' bytes; upload size:' , resizedBlob .size , ' bytes; dimension:' , dimension + ' px' )
137+ }
123138
124- canvas .toBlob ((blob ) => {
125- const request = api .uploadFile (blob, ' avatar.png' , null , MAX_AVATAR_SIZE , MAX_AVATAR_SIZE )
126- request .addEventListener (' load' , () => {
127- const response = JSON .parse (request .responseText )
139+ await new Promise ((resolve ) => {
140+ const request = api .uploadFile (resizedBlob, ' avatar.png' , null , dimension, dimension)
141+ const handleFailure = (status , responseText ) => {
142+ let message = proxy .$t (' profile/ChangeAvatar:error:upload-failed' )
143+ if (status === 413 ) {
144+ message = proxy .$t (' profile/ChangeAvatar:error:file-too-large' )
145+ }
146+ console .error (' [avatar-upload]' , status, responseText)
147+ fileError .value = message
148+ emit (' blockSave' , true )
149+ resolve ()
150+ }
151+ request .addEventListener (' load' , () => {
152+ const status = request .status
153+ const responseText = request .responseText || ' '
154+ const contentType = request .getResponseHeader (' content-type' ) || ' '
155+ if (status < 200 || status >= 300 || ! contentType .includes (' application/json' )) {
156+ return handleFailure (status, responseText)
157+ }
158+ try {
159+ const response = JSON .parse (responseText)
128160 emit (' update:modelValue' , { url: response .url })
161+ emit (' blockSave' , false )
129162 resolve ()
130- })
163+ } catch (error) {
164+ return handleFailure (status, responseText)
165+ }
166+ })
167+ request .addEventListener (' error' , () => {
168+ handleFailure (request .status , request .responseText )
169+ })
170+ })
171+ }
172+
173+ function createAvatarBlob (sourceCanvas ) {
174+ return new Promise ((resolve ) => {
175+ const sourceSize = Math .min (sourceCanvas .width , sourceCanvas .height )
176+ const targetSize = Math .min (UPLOAD_AVATAR_SIZE , Math .max (MIN_AVATAR_SIZE , sourceSize))
177+ const canvas = document .createElement (' canvas' )
178+ canvas .width = targetSize
179+ canvas .height = targetSize
180+ const ctx = canvas .getContext (' 2d' )
181+ if (! ctx) return resolve (null )
182+ ctx .imageSmoothingEnabled = true
183+ ctx .imageSmoothingQuality = ' high'
184+ ctx .drawImage (
185+ sourceCanvas,
186+ 0 ,
187+ 0 ,
188+ sourceCanvas .width ,
189+ sourceCanvas .height ,
190+ 0 ,
191+ 0 ,
192+ targetSize,
193+ targetSize
194+ )
195+ canvas .toBlob (blob => {
196+ if (! blob) return resolve (null )
197+ resolve ({ blob, dimension: targetSize })
131198 }, ' image/png' )
132199 })
133200}
0 commit comments