| 1 |
import { useState, useCallback, useEffect, useRef } from '@wordpress/element'; |
| 2 |
import apiFetch from '@wordpress/api-fetch'; |
| 3 |
import { __, sprintf } from '@wordpress/i18n'; |
| 4 |
import Cropper from 'react-easy-crop'; |
| 5 |
|
| 6 |
export default function Avatar() { |
| 7 |
const [ file, setFile ] = useState(); |
| 8 |
const [ width, setWidth ] = useState( 0 ); |
| 9 |
const [ height, setHeight ] = useState( 0 ); |
| 10 |
const [ crop, setCrop ] = useState( { x: 0, y: 0 } ); |
| 11 |
const [ rotation, setRotation ] = useState( 0 ); |
| 12 |
const [ croppedAreaPixels, setCroppedAreaPixels ] = useState( null ); |
| 13 |
const [ naturalWidth, setNaturalWidth ] = useState( 0 ); |
| 14 |
const [ naturalHeight, setNaturalHeight ] = useState( 0 ); |
| 15 |
const [ loading, setLoading ] = useState( false ); |
| 16 |
const [ skeleton, setSkeleton ] = useState( true ); |
| 17 |
const [ uploadError, setUploadError ] = useState( '' ); |
| 18 |
const [ notice, setNotice ] = useState( { |
| 19 |
type: '', |
| 20 |
message: '', |
| 21 |
} ); |
| 22 |
|
| 23 |
const fileInput = useRef(); |
| 24 |
|
| 25 |
useEffect( () => { |
| 26 |
async function getAvatar() { |
| 27 |
setSkeleton( true ); |
| 28 |
try { |
| 29 |
const response = await apiFetch( { |
| 30 |
method: 'GET', |
| 31 |
path: 'lp/v1/profile/get-avatar', |
| 32 |
} ); |
| 33 |
|
| 34 |
setWidth( response?.data?.width ? parseInt( response.data.width ) : 0 ); |
| 35 |
setHeight( response?.data?.height ? parseInt( response.data.height ) : 0 ); |
| 36 |
setFile( response?.data?.url ? response.data.url : '' ); |
| 37 |
} catch ( error ) { |
| 38 |
setNotice( { |
| 39 |
type: 'error', |
| 40 |
message: error.message, |
| 41 |
} ); |
| 42 |
} |
| 43 |
setSkeleton( false ); |
| 44 |
} |
| 45 |
|
| 46 |
getAvatar(); |
| 47 |
}, [] ); |
| 48 |
|
| 49 |
function readFile( file ) { |
| 50 |
return new Promise( ( resolve ) => { |
| 51 |
const reader = new FileReader(); |
| 52 |
reader.addEventListener( 'load', () => resolve( reader.result ), false ); |
| 53 |
reader.readAsDataURL( file ); |
| 54 |
} ); |
| 55 |
} |
| 56 |
|
| 57 |
const onCropComplete = useCallback( ( croppedArea, croppedAreaPixels ) => { |
| 58 |
setCroppedAreaPixels( croppedAreaPixels ); |
| 59 |
}, [] ); |
| 60 |
|
| 61 |
const base64Resize = ( base64, width, height ) => { |
| 62 |
return new Promise( ( resolve, reject ) => { |
| 63 |
const canvas = document.createElement( 'canvas' ); |
| 64 |
const img = document.createElement( 'img' ); |
| 65 |
img.src = base64; |
| 66 |
img.setAttribute( 'crossOrigin', 'anonymous' ); |
| 67 |
img.onload = () => { |
| 68 |
if ( img.naturalWidth > width || img.naturalHeight > height ) { |
| 69 |
canvas.width = width; |
| 70 |
canvas.height = height; |
| 71 |
const ctx = canvas.getContext( '2d' ); |
| 72 |
ctx.drawImage( img, 0, 0, width, height ); |
| 73 |
resolve( canvas.toDataURL( 'image/jpeg' ) ); |
| 74 |
} |
| 75 |
|
| 76 |
resolve( base64 ); |
| 77 |
}; |
| 78 |
img.onerror = ( err ) => reject( err ); |
| 79 |
} ); |
| 80 |
}; |
| 81 |
|
| 82 |
const updateAvatar = useCallback( async () => { |
| 83 |
setLoading( { save: true } ); |
| 84 |
|
| 85 |
try { |
| 86 |
const croppedImage = await getCroppedImg( |
| 87 |
file, |
| 88 |
croppedAreaPixels, |
| 89 |
rotation, |
| 90 |
); |
| 91 |
|
| 92 |
const imageResize = await base64Resize( croppedImage, width, height ); |
| 93 |
|
| 94 |
const response = await apiFetch( { |
| 95 |
path: 'lp/v1/profile/upload-avatar', |
| 96 |
method: 'POST', |
| 97 |
data: { file: imageResize || '' }, |
| 98 |
} ); |
| 99 |
|
| 100 |
const { data, status, message } = await response; |
| 101 |
|
| 102 |
if ( status === 'success' ) { |
| 103 |
window.location.reload(); |
| 104 |
} |
| 105 |
|
| 106 |
setNotice( { |
| 107 |
type: status, |
| 108 |
message, |
| 109 |
} ); |
| 110 |
} catch ( e ) { |
| 111 |
setNotice( { |
| 112 |
type: 'error', |
| 113 |
message: e.message || '', |
| 114 |
} ); |
| 115 |
} |
| 116 |
|
| 117 |
setLoading( { save: false } ); |
| 118 |
}, [ croppedAreaPixels, rotation ] ); |
| 119 |
|
| 120 |
const setFileInput = async ( fileUpload ) => { |
| 121 |
const file = await readFile( fileUpload ); |
| 122 |
|
| 123 |
const img = new Image(); |
| 124 |
img.src = await file; |
| 125 |
img.onload = await function() { |
| 126 |
setNaturalWidth( img.naturalWidth ); |
| 127 |
setNaturalHeight( img.naturalHeight ); |
| 128 |
|
| 129 |
let error = ''; |
| 130 |
if ( parseInt( fileUpload.size ) > 2097152 ) { |
| 131 |
error = __( 'File size too large. You need to upload a file < 2MB', 'learnpress' ); |
| 132 |
} else if ( img.naturalWidth < width || img.naturalHeight < height ) { |
| 133 |
error = sprintf( __( 'Image size must be greater than or equal to %1$sx%2$spx', 'learnpress' ), width, height ); |
| 134 |
} |
| 135 |
|
| 136 |
if ( error ) { |
| 137 |
setUploadError( error ); |
| 138 |
} else { |
| 139 |
setUploadError( '' ); |
| 140 |
setFile( file ); |
| 141 |
} |
| 142 |
}; |
| 143 |
}; |
| 144 |
|
| 145 |
async function removeAvatar() { |
| 146 |
if ( confirm( __( 'Are you sure you want to remove your avatar?', 'learnpress' ) ) ) { |
| 147 |
setLoading( { remove: true } ); |
| 148 |
try { |
| 149 |
const response = await apiFetch( { |
| 150 |
path: 'lp/v1/profile/remove-avatar', |
| 151 |
method: 'POST', |
| 152 |
} ); |
| 153 |
|
| 154 |
const { data, status, message } = await response; |
| 155 |
|
| 156 |
setNotice( { |
| 157 |
type: status, |
| 158 |
message, |
| 159 |
} ); |
| 160 |
|
| 161 |
setFile( '' ); |
| 162 |
} catch ( e ) { |
| 163 |
setNotice( { |
| 164 |
type: 'error', |
| 165 |
message: e.message || '', |
| 166 |
} ); |
| 167 |
} |
| 168 |
setLoading( { remove: false } ); |
| 169 |
} |
| 170 |
} |
| 171 |
|
| 172 |
return ( |
| 173 |
<div className="learnpress_avatar"> |
| 174 |
{ ! skeleton ? ( |
| 175 |
<> |
| 176 |
{ file && ! uploadError && ( |
| 177 |
<> |
| 178 |
{ naturalHeight && naturalWidth ? ( |
| 179 |
<div className="learnpress_avatar__cropper"> |
| 180 |
<div style={ { position: 'relative', width: naturalWidth, height: naturalHeight, zIndex: 9999, maxWidth: '100%', maxHeight: '800px' } }> |
| 181 |
<Cropper |
| 182 |
image={ file } |
| 183 |
crop={ crop } |
| 184 |
zoom="1" |
| 185 |
cropSize={ { width, height } } |
| 186 |
onCropChange={ setCrop } |
| 187 |
onCropComplete={ onCropComplete } |
| 188 |
/> |
| 189 |
</div> |
| 190 |
|
| 191 |
<div> |
| 192 |
<button className={ `learnpress_avatar__button learnpress_avatar__button--save ${ loading?.save ? 'learnpress_avatar__button--loading' : '' }` } onClick={ updateAvatar }>{ __( 'Save', 'learnpress' ) }</button> |
| 193 |
</div> |
| 194 |
</div> |
| 195 |
) : ( |
| 196 |
<div className="learnpress_avatar__cropper"> |
| 197 |
<img src={ file } alt="" /> |
| 198 |
|
| 199 |
<div> |
| 200 |
<button className={ `learnpress_avatar__button learnpress_avatar__button--replace` } onClick={ () => fileInput.current && fileInput.current.click() }>{ __( 'Replace', 'learnpress' ) }</button> |
| 201 |
<button className={ `learnpress_avatar__button learnpress_avatar__button--remove ${ loading?.remove ? 'learnpress_avatar__button--loading' : '' }` } onClick={ removeAvatar }>{ __( 'Remove', 'learnpress' ) }</button> |
| 202 |
</div> |
| 203 |
</div> |
| 204 |
) } |
| 205 |
</> |
| 206 |
) } |
| 207 |
|
| 208 |
<form style={ { display: ! file ? '' : 'none' } }> |
| 209 |
<div className="learnpress_avatar__form"> |
| 210 |
<div className="learnpress_avatar__form-group"> |
| 211 |
<label htmlFor="avatar-file"> |
| 212 |
<div className="learnpress_avatar__form__upload"> |
| 213 |
<div> |
| 214 |
<span><svg viewBox="64 64 896 896" focusable="false" data-icon="plus" width="1em" height="1em" fill="currentColor" aria-hidden="true"><defs><style></style></defs><path d="M482 152h60q8 0 8 8v704q0 8-8 8h-60q-8 0-8-8V160q0-8 8-8z"></path><path d="M176 474h672q8 0 8 8v60q0 8-8 8H176q-8 0-8-8v-60q0-8 8-8z"></path></svg></span> |
| 215 |
<div>{ __( 'Upload', 'learnpress' ) }</div> |
| 216 |
</div> |
| 217 |
</div> |
| 218 |
<input ref={ fileInput } type="file" id="avatar-file" accept="image/*" onChange={ ( e ) => setFileInput( e.target.files && e.target.files.length > 0 ? e.target.files[ 0 ] : '' ) } /> |
| 219 |
</label> |
| 220 |
</div> |
| 221 |
</div> |
| 222 |
</form> |
| 223 |
|
| 224 |
{ uploadError && ( |
| 225 |
<div className={ `lp-ajax-message error` } style={ { display: 'block' } }>{ uploadError }</div> |
| 226 |
) } |
| 227 |
|
| 228 |
{ ! uploadError && notice && notice.type && notice.message && ( |
| 229 |
<div className={ `lp-ajax-message ${ notice.type }` } style={ { display: 'block' } }>{ notice.message }</div> |
| 230 |
) } |
| 231 |
</> |
| 232 |
) : ( |
| 233 |
<ul className="lp-skeleton-animation"> |
| 234 |
<li style={ { width: 200, height: 200 } }></li> |
| 235 |
<li style={ { width: 200, height: 20 } }></li> |
| 236 |
<li style={ { width: 200, height: 20 } }></li> |
| 237 |
</ul> |
| 238 |
) } |
| 239 |
</div> |
| 240 |
); |
| 241 |
} |
| 242 |
|
| 243 |
// Link: https://codesandbox.io/s/q8q1mnr01w |
| 244 |
const createImage = ( url ) => |
| 245 |
new Promise( ( resolve, reject ) => { |
| 246 |
const image = new Image(); |
| 247 |
image.addEventListener( 'load', () => resolve( image ) ); |
| 248 |
image.addEventListener( 'error', ( error ) => reject( error ) ); |
| 249 |
image.setAttribute( 'crossOrigin', 'anonymous' ); // needed to avoid cross-origin issues on CodeSandbox |
| 250 |
image.src = url; |
| 251 |
} ); |
| 252 |
|
| 253 |
function getRadianAngle( degreeValue ) { |
| 254 |
return ( degreeValue * Math.PI ) / 180; |
| 255 |
} |
| 256 |
|
| 257 |
/** |
| 258 |
* Returns the new bounding area of a rotated rectangle. |
| 259 |
* |
| 260 |
* @param width |
| 261 |
* @param height |
| 262 |
* @param rotation |
| 263 |
*/ |
| 264 |
function rotateSize( width, height, rotation ) { |
| 265 |
const rotRad = getRadianAngle( rotation ); |
| 266 |
|
| 267 |
return { |
| 268 |
width: |
| 269 |
Math.abs( Math.cos( rotRad ) * width ) + Math.abs( Math.sin( rotRad ) * height ), |
| 270 |
height: |
| 271 |
Math.abs( Math.sin( rotRad ) * width ) + Math.abs( Math.cos( rotRad ) * height ), |
| 272 |
}; |
| 273 |
} |
| 274 |
|
| 275 |
/** |
| 276 |
* This function was adapted from the one in the ReadMe of https://github.com/DominicTobias/react-image-crop |
| 277 |
* |
| 278 |
* @param imageSrc |
| 279 |
* @param pixelCrop |
| 280 |
* @param rotation |
| 281 |
* @param flip |
| 282 |
*/ |
| 283 |
async function getCroppedImg( |
| 284 |
imageSrc, |
| 285 |
pixelCrop, |
| 286 |
rotation = 0, |
| 287 |
flip = { horizontal: false, vertical: false } |
| 288 |
) { |
| 289 |
const image = await createImage( imageSrc ); |
| 290 |
const canvas = document.createElement( 'canvas' ); |
| 291 |
const ctx = canvas.getContext( '2d' ); |
| 292 |
|
| 293 |
if ( ! ctx ) { |
| 294 |
return null; |
| 295 |
} |
| 296 |
|
| 297 |
const rotRad = getRadianAngle( rotation ); |
| 298 |
|
| 299 |
// calculate bounding box of the rotated image |
| 300 |
const { width: bBoxWidth, height: bBoxHeight } = rotateSize( |
| 301 |
image.width, |
| 302 |
image.height, |
| 303 |
rotation |
| 304 |
); |
| 305 |
|
| 306 |
// set canvas size to match the bounding box |
| 307 |
canvas.width = bBoxWidth; |
| 308 |
canvas.height = bBoxHeight; |
| 309 |
|
| 310 |
// translate canvas context to a central location to allow rotating and flipping around the center |
| 311 |
ctx.translate( bBoxWidth / 2, bBoxHeight / 2 ); |
| 312 |
ctx.rotate( rotRad ); |
| 313 |
ctx.scale( flip.horizontal ? -1 : 1, flip.vertical ? -1 : 1 ); |
| 314 |
ctx.translate( -image.width / 2, -image.height / 2 ); |
| 315 |
|
| 316 |
// draw rotated image |
| 317 |
ctx.drawImage( image, 0, 0 ); |
| 318 |
|
| 319 |
// croppedAreaPixels values are bounding box relative |
| 320 |
// extract the cropped image using these values |
| 321 |
const data = ctx.getImageData( |
| 322 |
pixelCrop.x, |
| 323 |
pixelCrop.y, |
| 324 |
pixelCrop.width, |
| 325 |
pixelCrop.height |
| 326 |
); |
| 327 |
|
| 328 |
// set canvas width to final desired crop size - this will clear existing context |
| 329 |
canvas.width = pixelCrop.width; |
| 330 |
canvas.height = pixelCrop.height; |
| 331 |
|
| 332 |
// paste generated rotate image at the top left corner |
| 333 |
ctx.putImageData( data, 0, 0 ); |
| 334 |
|
| 335 |
// As Base64 string |
| 336 |
return canvas.toDataURL( 'image/jpeg' ); |
| 337 |
} |
| 338 |
|