Merge pull request #218 from grant-project/avatar-upload-basics
Avatar upload and download
This commit is contained in:
commit
0496b58130
|
@ -8,4 +8,6 @@ SECRET_KEY="not-so-secret"
|
||||||
SENDGRID_API_KEY="optional, but emails won't send without it"
|
SENDGRID_API_KEY="optional, but emails won't send without it"
|
||||||
# for ropsten use the following
|
# for ropsten use the following
|
||||||
# ETHEREUM_ENDPOINT_URI = "https://ropsten.infura.io/API_KEY"
|
# ETHEREUM_ENDPOINT_URI = "https://ropsten.infura.io/API_KEY"
|
||||||
ETHEREUM_ENDPOINT_URI = "http://localhost:8545"
|
ETHEREUM_ENDPOINT_URI = "http://localhost:8545"
|
||||||
|
UPLOAD_DIRECTORY = "/tmp"
|
||||||
|
UPLOAD_URL = "http://localhost:5000" # for constructing download url
|
|
@ -27,3 +27,6 @@ SENDGRID_API_KEY = env.str("SENDGRID_API_KEY", default="")
|
||||||
SENDGRID_DEFAULT_FROM = "noreply@grant.io"
|
SENDGRID_DEFAULT_FROM = "noreply@grant.io"
|
||||||
ETHEREUM_PROVIDER = "http"
|
ETHEREUM_PROVIDER = "http"
|
||||||
ETHEREUM_ENDPOINT_URI = env.str("ETHEREUM_ENDPOINT_URI")
|
ETHEREUM_ENDPOINT_URI = env.str("ETHEREUM_ENDPOINT_URI")
|
||||||
|
UPLOAD_DIRECTORY = env.str("UPLOAD_DIRECTORY")
|
||||||
|
UPLOAD_URL = env.str("UPLOAD_URL")
|
||||||
|
MAX_CONTENT_LENGTH = 5 * 1024 * 1024 # 5MB (limits file uploads, raises RequestEntityTooLarge)
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
from flask import Blueprint, g
|
from flask import Blueprint, g, request
|
||||||
from flask_yoloapi import endpoint, parameter
|
from flask_yoloapi import endpoint, parameter
|
||||||
|
|
||||||
from grant.proposal.models import Proposal, proposal_team
|
from grant.proposal.models import Proposal, proposal_team
|
||||||
from grant.utils.auth import requires_sm, requires_same_user_auth, verify_signed_auth, BadSignatureException
|
from grant.utils.auth import requires_sm, requires_same_user_auth, verify_signed_auth, BadSignatureException
|
||||||
|
from grant.utils.upload import save_avatar, send_upload, remove_avatar
|
||||||
|
from grant.settings import UPLOAD_URL
|
||||||
from .models import User, SocialMedia, Avatar, users_schema, user_schema, db
|
from .models import User, SocialMedia, Avatar, users_schema, user_schema, db
|
||||||
|
|
||||||
blueprint = Blueprint('user', __name__, url_prefix='/api/v1/users')
|
blueprint = Blueprint('user', __name__, url_prefix='/api/v1/users')
|
||||||
|
@ -69,11 +71,11 @@ def create_user(
|
||||||
sig_address = verify_signed_auth(signed_message, raw_typed_data)
|
sig_address = verify_signed_auth(signed_message, raw_typed_data)
|
||||||
if sig_address.lower() != account_address.lower():
|
if sig_address.lower() != account_address.lower():
|
||||||
return {
|
return {
|
||||||
"message": "Message signature address ({sig_address}) doesn't match account_address ({account_address})".format(
|
"message": "Message signature address ({sig_address}) doesn't match account_address ({account_address})".format(
|
||||||
sig_address=sig_address,
|
sig_address=sig_address,
|
||||||
account_address=account_address
|
account_address=account_address
|
||||||
)
|
)
|
||||||
}, 400
|
}, 400
|
||||||
except BadSignatureException:
|
except BadSignatureException:
|
||||||
return {"message": "Invalid message signature"}, 400
|
return {"message": "Invalid message signature"}, 400
|
||||||
|
|
||||||
|
@ -103,17 +105,49 @@ def auth_user(account_address, signed_message, raw_typed_data):
|
||||||
sig_address = verify_signed_auth(signed_message, raw_typed_data)
|
sig_address = verify_signed_auth(signed_message, raw_typed_data)
|
||||||
if sig_address.lower() != account_address.lower():
|
if sig_address.lower() != account_address.lower():
|
||||||
return {
|
return {
|
||||||
"message": "Message signature address ({sig_address}) doesn't match account_address ({account_address})".format(
|
"message": "Message signature address ({sig_address}) doesn't match account_address ({account_address})".format(
|
||||||
sig_address=sig_address,
|
sig_address=sig_address,
|
||||||
account_address=account_address
|
account_address=account_address
|
||||||
)
|
)
|
||||||
}, 400
|
}, 400
|
||||||
except BadSignatureException:
|
except BadSignatureException:
|
||||||
return {"message": "Invalid message signature"}, 400
|
return {"message": "Invalid message signature"}, 400
|
||||||
|
|
||||||
return user_schema.dump(existing_user)
|
return user_schema.dump(existing_user)
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/avatar", methods=["POST"])
|
||||||
|
@requires_sm
|
||||||
|
@endpoint.api()
|
||||||
|
def upload_avatar():
|
||||||
|
user = g.current_user
|
||||||
|
if 'file' not in request.files:
|
||||||
|
return {"message": "No file in post"}, 400
|
||||||
|
file = request.files['file']
|
||||||
|
if file.filename == '':
|
||||||
|
return {"message": "No selected file"}, 400
|
||||||
|
try:
|
||||||
|
filename = save_avatar(file, user.id)
|
||||||
|
return {"url": "{0}/api/v1/users/avatar/{1}".format(UPLOAD_URL, filename)}
|
||||||
|
except Exception as e:
|
||||||
|
return {"message": str(e)}, 400
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/avatar/<filename>", methods=["GET"])
|
||||||
|
def get_avatar(filename):
|
||||||
|
return send_upload(filename)
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/avatar", methods=["DELETE"])
|
||||||
|
@requires_sm
|
||||||
|
@endpoint.api(
|
||||||
|
parameter('url', type=str, required=True)
|
||||||
|
)
|
||||||
|
def delete_avatar(url):
|
||||||
|
user = g.current_user
|
||||||
|
remove_avatar(url, user.id)
|
||||||
|
|
||||||
|
|
||||||
@blueprint.route("/<user_identity>", methods=["PUT"])
|
@blueprint.route("/<user_identity>", methods=["PUT"])
|
||||||
@requires_sm
|
@requires_sm
|
||||||
@requires_same_user_auth
|
@requires_same_user_auth
|
||||||
|
@ -140,6 +174,7 @@ def update_user(user_identity, display_name, title, social_medias, avatar):
|
||||||
else:
|
else:
|
||||||
SocialMedia.query.filter_by(user_id=user.id).delete()
|
SocialMedia.query.filter_by(user_id=user.id).delete()
|
||||||
|
|
||||||
|
old_avatar = Avatar.query.filter_by(user_id=user.id).first()
|
||||||
if avatar is not None:
|
if avatar is not None:
|
||||||
Avatar.query.filter_by(user_id=user.id).delete()
|
Avatar.query.filter_by(user_id=user.id).delete()
|
||||||
avatar_link = avatar.get('link')
|
avatar_link = avatar.get('link')
|
||||||
|
@ -149,6 +184,11 @@ def update_user(user_identity, display_name, title, social_medias, avatar):
|
||||||
else:
|
else:
|
||||||
Avatar.query.filter_by(user_id=user.id).delete()
|
Avatar.query.filter_by(user_id=user.id).delete()
|
||||||
|
|
||||||
|
old_avatar_url = old_avatar and old_avatar.image_url
|
||||||
|
new_avatar_url = avatar and avatar['link']
|
||||||
|
if old_avatar_url and old_avatar_url != new_avatar_url:
|
||||||
|
remove_avatar(old_avatar_url, user.id)
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
result = user_schema.dump(user)
|
result = user_schema.dump(user)
|
||||||
return result
|
return result
|
||||||
|
|
|
@ -0,0 +1,54 @@
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from hashlib import md5
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
from flask import send_from_directory
|
||||||
|
from grant.settings import UPLOAD_DIRECTORY
|
||||||
|
|
||||||
|
IMAGE_MIME_TYPES = set(['image/png', 'image/jpg', 'image/gif'])
|
||||||
|
AVATAR_MAX_SIZE = 2 * 1024 * 1024 # 2MB
|
||||||
|
|
||||||
|
|
||||||
|
class FileValidationException(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def allowed_avatar_file(file):
|
||||||
|
if file.mimetype not in IMAGE_MIME_TYPES:
|
||||||
|
raise FileValidationException("Unacceptable file type: {0}".format(file.mimetype))
|
||||||
|
file.seek(0, os.SEEK_END)
|
||||||
|
size = file.tell()
|
||||||
|
file.seek(0)
|
||||||
|
if size > AVATAR_MAX_SIZE:
|
||||||
|
raise FileValidationException(
|
||||||
|
"File size is too large ({0}KB), max size is {1}KB".format(size / 1024, AVATAR_MAX_SIZE / 1024)
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def hash_file(file):
|
||||||
|
hasher = md5()
|
||||||
|
buf = file.read()
|
||||||
|
hasher.update(buf)
|
||||||
|
file.seek(0)
|
||||||
|
return hasher.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def save_avatar(file, user_id):
|
||||||
|
if file and allowed_avatar_file(file):
|
||||||
|
ext = file.mimetype.replace('image/', '')
|
||||||
|
filename = "{0}.{1}.{2}".format(user_id, hash_file(file), ext)
|
||||||
|
file.save(os.path.join(UPLOAD_DIRECTORY, filename))
|
||||||
|
return filename
|
||||||
|
|
||||||
|
|
||||||
|
def remove_avatar(url, user_id):
|
||||||
|
match = re.search(r'/api/v1/users/avatar/(\d+.\w+.\w+)', url)
|
||||||
|
if match:
|
||||||
|
filename = match.group(1)
|
||||||
|
if filename.startswith(str(user_id) + '.'):
|
||||||
|
os.remove(os.path.join(UPLOAD_DIRECTORY, filename))
|
||||||
|
|
||||||
|
|
||||||
|
def send_upload(filename):
|
||||||
|
return send_from_directory(UPLOAD_DIRECTORY, secure_filename(filename))
|
|
@ -0,0 +1,74 @@
|
||||||
|
@small-query: ~'(max-width: 500px)';
|
||||||
|
|
||||||
|
.AvatarEdit {
|
||||||
|
&-avatar {
|
||||||
|
position: relative;
|
||||||
|
height: 10.5rem;
|
||||||
|
width: 10.5rem;
|
||||||
|
margin-right: 1.25rem;
|
||||||
|
align-self: start;
|
||||||
|
|
||||||
|
@media @small-query {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-img {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-change {
|
||||||
|
position: absolute;
|
||||||
|
display: flex;
|
||||||
|
flex-flow: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
border-radius: 1rem;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:hover:focus {
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
color: #ffffff;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
color: #ffffff;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&-icon {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&-delete {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.2rem;
|
||||||
|
right: 0.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #ffffff;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:hover:focus {
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
color: #ffffff;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,176 @@
|
||||||
|
import React from 'react';
|
||||||
|
import axios from 'api/axios';
|
||||||
|
import { Upload, Icon, Modal, Button, Alert } from 'antd';
|
||||||
|
import Cropper from 'react-cropper';
|
||||||
|
import 'cropperjs/dist/cropper.css';
|
||||||
|
import { UploadFile } from 'antd/lib/upload/interface';
|
||||||
|
import { TeamMember } from 'types';
|
||||||
|
import { getBase64 } from 'utils/blob';
|
||||||
|
import UserAvatar from 'components/UserAvatar';
|
||||||
|
import './AvatarEdit.less';
|
||||||
|
|
||||||
|
const FILE_TYPES = ['image/jpeg', 'image/png', 'image/gif'];
|
||||||
|
const FILE_MAX_LOAD_MB = 10;
|
||||||
|
|
||||||
|
interface OwnProps {
|
||||||
|
user: TeamMember;
|
||||||
|
onDelete(): void;
|
||||||
|
onDone(url: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
isUploading: false,
|
||||||
|
showModal: false,
|
||||||
|
newAvatarUrl: '',
|
||||||
|
loadError: '',
|
||||||
|
uploadError: '',
|
||||||
|
};
|
||||||
|
type State = typeof initialState;
|
||||||
|
|
||||||
|
type Props = OwnProps;
|
||||||
|
|
||||||
|
export default class AvatarEdit extends React.PureComponent<Props, State> {
|
||||||
|
state = initialState;
|
||||||
|
cropperRef: React.RefObject<any>;
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props);
|
||||||
|
this.cropperRef = React.createRef();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { newAvatarUrl, showModal, loadError, uploadError, isUploading } = this.state;
|
||||||
|
const {
|
||||||
|
user,
|
||||||
|
user: { avatarUrl },
|
||||||
|
} = this.props;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
<div className="AvatarEdit-avatar">
|
||||||
|
<UserAvatar className="AvatarEdit-avatar-img" user={user} />
|
||||||
|
<Upload
|
||||||
|
name="avatar"
|
||||||
|
showUploadList={false}
|
||||||
|
action={this.handleLoad}
|
||||||
|
beforeUpload={this.beforeLoad}
|
||||||
|
onChange={this.handleLoadChange}
|
||||||
|
>
|
||||||
|
<Button className="AvatarEdit-avatar-change">
|
||||||
|
<Icon
|
||||||
|
className="AvatarEdit-avatar-change-icon"
|
||||||
|
type={avatarUrl ? 'picture' : 'plus-circle'}
|
||||||
|
/>
|
||||||
|
<div>{avatarUrl ? 'Change photo' : 'Add photo'}</div>
|
||||||
|
</Button>
|
||||||
|
</Upload>
|
||||||
|
{avatarUrl && (
|
||||||
|
<Button
|
||||||
|
className="AvatarEdit-avatar-delete"
|
||||||
|
icon="delete"
|
||||||
|
shape="circle"
|
||||||
|
onClick={this.props.onDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{loadError && (
|
||||||
|
<Alert message={loadError} type="error" style={{ margin: '0.5rem 0 0 0' }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Modal
|
||||||
|
title="Prepare your avatar"
|
||||||
|
visible={showModal}
|
||||||
|
footer={[
|
||||||
|
<Button key="back" onClick={this.handleClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>,
|
||||||
|
<Button
|
||||||
|
key="submit"
|
||||||
|
type="primary"
|
||||||
|
loading={isUploading}
|
||||||
|
onClick={this.handleUpload}
|
||||||
|
>
|
||||||
|
Upload
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Cropper
|
||||||
|
ref={this.cropperRef}
|
||||||
|
src={newAvatarUrl}
|
||||||
|
style={{ height: 300 }}
|
||||||
|
aspectRatio={1}
|
||||||
|
guides={false}
|
||||||
|
viewMode={1}
|
||||||
|
/>
|
||||||
|
{uploadError && (
|
||||||
|
<Alert
|
||||||
|
message={uploadError}
|
||||||
|
type="error"
|
||||||
|
style={{ margin: '0.5rem 0 0 0' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleClose = () => {
|
||||||
|
this.setState({
|
||||||
|
isUploading: false,
|
||||||
|
showModal: false,
|
||||||
|
newAvatarUrl: '',
|
||||||
|
uploadError: '',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
private handleLoadChange = (info: any) => {
|
||||||
|
if (info.file.status === 'done') {
|
||||||
|
getBase64(info.file.originFileObj, newAvatarUrl =>
|
||||||
|
this.setState({
|
||||||
|
newAvatarUrl,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private beforeLoad = (file: UploadFile) => {
|
||||||
|
this.setState({ loadError: '' });
|
||||||
|
const isTypeOk = !!FILE_TYPES.find(t => t === file.type);
|
||||||
|
if (!isTypeOk) {
|
||||||
|
this.setState({ loadError: 'File must be a jpg, png or gif' });
|
||||||
|
}
|
||||||
|
const isSizeOk = file.size / 1024 / 1024 < FILE_MAX_LOAD_MB;
|
||||||
|
if (!isSizeOk) {
|
||||||
|
this.setState({
|
||||||
|
loadError: `File size must be less than ${FILE_MAX_LOAD_MB}MB`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return isTypeOk && isSizeOk;
|
||||||
|
};
|
||||||
|
|
||||||
|
private handleLoad = () => {
|
||||||
|
this.setState({ showModal: true });
|
||||||
|
return Promise.resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
private handleUpload = () => {
|
||||||
|
this.cropperRef.current
|
||||||
|
.getCroppedCanvas({ width: 400, height: 400 })
|
||||||
|
.toBlob((blob: Blob) => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', blob);
|
||||||
|
this.setState({ isUploading: true });
|
||||||
|
axios
|
||||||
|
.post('/api/v1/users/avatar', formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(res => {
|
||||||
|
this.props.onDone(res.data.url);
|
||||||
|
this.handleClose();
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
this.setState({ isUploading: false, uploadError: err.message });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
|
@ -7,12 +7,12 @@
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
background: rgba(0, 0, 0, 0.3);
|
background: rgba(0, 0, 0, 0.3);
|
||||||
z-index: 1000;
|
z-index: 900;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ProfileEdit {
|
.ProfileEdit {
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1001;
|
z-index: 901;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
|
@ -28,77 +28,6 @@
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
&-avatar {
|
|
||||||
position: relative;
|
|
||||||
height: 10.5rem;
|
|
||||||
width: 10.5rem;
|
|
||||||
margin-right: 1.25rem;
|
|
||||||
align-self: start;
|
|
||||||
|
|
||||||
@media @small-query {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-img {
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
border-radius: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-change {
|
|
||||||
position: absolute;
|
|
||||||
display: flex;
|
|
||||||
flex-flow: column;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
color: #ffffff;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
font-weight: 600;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: rgba(0, 0, 0, 0.4);
|
|
||||||
border-radius: 1rem;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
&:hover,
|
|
||||||
&:hover:focus {
|
|
||||||
background: rgba(0, 0, 0, 0.6);
|
|
||||||
color: #ffffff;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
background: rgba(0, 0, 0, 0.4);
|
|
||||||
color: #ffffff;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
&-icon {
|
|
||||||
font-size: 2rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&-delete {
|
|
||||||
position: absolute;
|
|
||||||
top: 0.2rem;
|
|
||||||
right: 0.2rem;
|
|
||||||
cursor: pointer;
|
|
||||||
color: #ffffff;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
|
|
||||||
&:hover,
|
|
||||||
&:hover:focus {
|
|
||||||
background: rgba(0, 0, 0, 0.4);
|
|
||||||
color: #ffffff;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&-info {
|
&-info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import lodash from 'lodash';
|
import lodash from 'lodash';
|
||||||
import { Input, Form, Col, Row, Button, Icon, Alert } from 'antd';
|
import axios from 'api/axios';
|
||||||
|
import { Input, Form, Col, Row, Button, Alert } from 'antd';
|
||||||
import { SOCIAL_INFO } from 'utils/social';
|
import { SOCIAL_INFO } from 'utils/social';
|
||||||
import { SOCIAL_TYPE, TeamMember } from 'types';
|
import { SOCIAL_TYPE, TeamMember } from 'types';
|
||||||
import { UserState } from 'modules/users/reducers';
|
import { UserState } from 'modules/users/reducers';
|
||||||
import { getCreateTeamMemberError } from 'modules/create/utils';
|
import { getCreateTeamMemberError } from 'modules/create/utils';
|
||||||
import UserAvatar from 'components/UserAvatar';
|
import AvatarEdit from './AvatarEdit';
|
||||||
import './ProfileEdit.less';
|
import './ProfileEdit.less';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
@ -54,27 +55,12 @@ export default class ProfileEdit extends React.PureComponent<Props, State> {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="ProfileEdit">
|
<div className="ProfileEdit">
|
||||||
<div className="ProfileEdit-avatar">
|
<AvatarEdit
|
||||||
<UserAvatar className="ProfileEdit-avatar-img" user={fields} />
|
user={fields}
|
||||||
<Button
|
onDone={this.handleChangePhoto}
|
||||||
className="ProfileEdit-avatar-change"
|
onDelete={this.handleDeletePhoto}
|
||||||
onClick={this.handleChangePhoto}
|
/>
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
className="ProfileEdit-avatar-change-icon"
|
|
||||||
type={fields.avatarUrl ? 'picture' : 'plus-circle'}
|
|
||||||
/>
|
|
||||||
<div>{fields.avatarUrl ? 'Change photo' : 'Add photo'}</div>
|
|
||||||
</Button>
|
|
||||||
{fields.avatarUrl && (
|
|
||||||
<Button
|
|
||||||
className="ProfileEdit-avatar-delete"
|
|
||||||
icon="delete"
|
|
||||||
shape="circle"
|
|
||||||
onClick={this.handleDeletePhoto}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="ProfileEdit-info">
|
<div className="ProfileEdit-info">
|
||||||
<Form
|
<Form
|
||||||
className="ProfileEdit-info-form"
|
className="ProfileEdit-info-form"
|
||||||
|
@ -187,6 +173,13 @@ export default class ProfileEdit extends React.PureComponent<Props, State> {
|
||||||
};
|
};
|
||||||
|
|
||||||
private handleCancel = () => {
|
private handleCancel = () => {
|
||||||
|
const { avatarUrl } = this.state.fields;
|
||||||
|
// cleanup uploaded file if we cancel
|
||||||
|
if (this.props.user.avatarUrl !== avatarUrl && avatarUrl) {
|
||||||
|
axios.delete('/api/v1/users/avatar', {
|
||||||
|
params: { url: avatarUrl },
|
||||||
|
});
|
||||||
|
}
|
||||||
this.props.onDone();
|
this.props.onDone();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -226,13 +219,10 @@ export default class ProfileEdit extends React.PureComponent<Props, State> {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
private handleChangePhoto = () => {
|
private handleChangePhoto = (url: string) => {
|
||||||
// TODO: Actual file uploading
|
|
||||||
const gender = ['men', 'women'][Math.floor(Math.random() * 2)];
|
|
||||||
const num = Math.floor(Math.random() * 80);
|
|
||||||
const fields = {
|
const fields = {
|
||||||
...this.state.fields,
|
...this.state.fields,
|
||||||
avatarUrl: `https://randomuser.me/api/portraits/${gender}/${num}.jpg`,
|
avatarUrl: url,
|
||||||
};
|
};
|
||||||
const isChanged = this.isChangedCheck(fields);
|
const isChanged = this.isChangedCheck(fields);
|
||||||
this.setState({
|
this.setState({
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import types from './types';
|
import types from './types';
|
||||||
|
import usersTypes from 'modules/users/types';
|
||||||
// TODO: Use a common User type instead of this
|
// TODO: Use a common User type instead of this
|
||||||
import { TeamMember, AuthSignatureData } from 'types';
|
import { TeamMember, AuthSignatureData } from 'types';
|
||||||
|
|
||||||
|
@ -56,6 +57,14 @@ export default function createReducer(
|
||||||
authSignatureAddress: action.payload.user.ethAddress,
|
authSignatureAddress: action.payload.user.ethAddress,
|
||||||
isAuthingUser: false,
|
isAuthingUser: false,
|
||||||
};
|
};
|
||||||
|
case usersTypes.UPDATE_USER_FULFILLED:
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
user:
|
||||||
|
state.user && state.user.ethAddress === action.payload.user.ethAddress
|
||||||
|
? action.payload.user
|
||||||
|
: state.user,
|
||||||
|
};
|
||||||
case types.AUTH_USER_REJECTED:
|
case types.AUTH_USER_REJECTED:
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
|
|
|
@ -0,0 +1,35 @@
|
||||||
|
export function getBase64(img: Blob, callback: (base64: string) => void) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.addEventListener('load', () => callback(reader.result as string));
|
||||||
|
reader.readAsDataURL(img);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dataUrlToBlob(dataUrl: string) {
|
||||||
|
const base64ImageContent = dataUrl.replace(/^data:[a-z]+\/[a-z]+;base64,/, '');
|
||||||
|
const mimeSearch = dataUrl.match(/^data:([a-z]+\/[a-z]+)(;base64,)/);
|
||||||
|
if (!mimeSearch || mimeSearch.length !== 3) {
|
||||||
|
throw new Error(
|
||||||
|
'dataUrlToBlob could not find mime type, or base64 was missing: ' +
|
||||||
|
dataUrl.substring(0, 200),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return base64ToBlob(base64ImageContent, mimeSearch[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function base64ToBlob(base64: string, mime: string) {
|
||||||
|
mime = mime || '';
|
||||||
|
const sliceSize = 1024;
|
||||||
|
const byteChars = window.atob(base64);
|
||||||
|
const byteArrays = [];
|
||||||
|
for (let offset = 0, len = byteChars.length; offset < len; offset += sliceSize) {
|
||||||
|
const slice = byteChars.slice(offset, offset + sliceSize);
|
||||||
|
const byteNumbers = new Array(slice.length);
|
||||||
|
for (let i = 0; i < slice.length; i++) {
|
||||||
|
byteNumbers[i] = slice.charCodeAt(i);
|
||||||
|
}
|
||||||
|
const byteArray = new Uint8Array(byteNumbers);
|
||||||
|
byteArrays.push(byteArray);
|
||||||
|
}
|
||||||
|
return new Blob(byteArrays, { type: mime });
|
||||||
|
}
|
|
@ -55,6 +55,7 @@
|
||||||
"@types/node": "^10.3.1",
|
"@types/node": "^10.3.1",
|
||||||
"@types/numeral": "^0.0.25",
|
"@types/numeral": "^0.0.25",
|
||||||
"@types/react": "16.4.18",
|
"@types/react": "16.4.18",
|
||||||
|
"@types/react-cropper": "^0.10.3",
|
||||||
"@types/react-dom": "16.0.9",
|
"@types/react-dom": "16.0.9",
|
||||||
"@types/react-helmet": "^5.0.7",
|
"@types/react-helmet": "^5.0.7",
|
||||||
"@types/react-redux": "^6.0.2",
|
"@types/react-redux": "^6.0.2",
|
||||||
|
@ -118,6 +119,7 @@
|
||||||
"prettier-package-json": "^1.6.0",
|
"prettier-package-json": "^1.6.0",
|
||||||
"query-string": "6.1.0",
|
"query-string": "6.1.0",
|
||||||
"react": "16.5.2",
|
"react": "16.5.2",
|
||||||
|
"react-cropper": "^1.0.1",
|
||||||
"react-dev-utils": "^5.0.2",
|
"react-dev-utils": "^5.0.2",
|
||||||
"react-dom": "16.5.2",
|
"react-dom": "16.5.2",
|
||||||
"react-helmet": "^5.2.0",
|
"react-helmet": "^5.2.0",
|
||||||
|
|
|
@ -1858,6 +1858,10 @@
|
||||||
dependencies:
|
dependencies:
|
||||||
"@types/express" "*"
|
"@types/express" "*"
|
||||||
|
|
||||||
|
"@types/cropperjs@*":
|
||||||
|
version "1.1.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/cropperjs/-/cropperjs-1.1.3.tgz#8b2264fb45e933c3eda149cbd08a4f1926dfd8e2"
|
||||||
|
|
||||||
"@types/dotenv@^4.0.3":
|
"@types/dotenv@^4.0.3":
|
||||||
version "4.0.3"
|
version "4.0.3"
|
||||||
resolved "https://registry.yarnpkg.com/@types/dotenv/-/dotenv-4.0.3.tgz#ebcfc40da7bc0728b705945b7db48485ec5b4b67"
|
resolved "https://registry.yarnpkg.com/@types/dotenv/-/dotenv-4.0.3.tgz#ebcfc40da7bc0728b705945b7db48485ec5b4b67"
|
||||||
|
@ -1967,6 +1971,13 @@
|
||||||
version "1.2.2"
|
version "1.2.2"
|
||||||
resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.2.tgz#fa8e1ad1d474688a757140c91de6dace6f4abc8d"
|
resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.2.tgz#fa8e1ad1d474688a757140c91de6dace6f4abc8d"
|
||||||
|
|
||||||
|
"@types/react-cropper@^0.10.3":
|
||||||
|
version "0.10.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/react-cropper/-/react-cropper-0.10.3.tgz#d7ca18667d9cdad9469d3de6469104924d8217d5"
|
||||||
|
dependencies:
|
||||||
|
"@types/cropperjs" "*"
|
||||||
|
"@types/react" "*"
|
||||||
|
|
||||||
"@types/react-dom@16.0.9":
|
"@types/react-dom@16.0.9":
|
||||||
version "16.0.9"
|
version "16.0.9"
|
||||||
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.0.9.tgz#73ceb7abe6703822eab6600e65c5c52efd07fb91"
|
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.0.9.tgz#73ceb7abe6703822eab6600e65c5c52efd07fb91"
|
||||||
|
@ -4765,6 +4776,10 @@ create-react-context@0.2.3:
|
||||||
fbjs "^0.8.0"
|
fbjs "^0.8.0"
|
||||||
gud "^1.0.0"
|
gud "^1.0.0"
|
||||||
|
|
||||||
|
cropperjs@v1.0.0-rc.3:
|
||||||
|
version "1.0.0-rc.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/cropperjs/-/cropperjs-1.0.0-rc.3.tgz#50a7c7611befc442702f845ede77d7df4572e82b"
|
||||||
|
|
||||||
cross-env@^5.2.0:
|
cross-env@^5.2.0:
|
||||||
version "5.2.0"
|
version "5.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-5.2.0.tgz#6ecd4c015d5773e614039ee529076669b9d126f2"
|
resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-5.2.0.tgz#6ecd4c015d5773e614039ee529076669b9d126f2"
|
||||||
|
@ -12512,6 +12527,13 @@ react-copy-to-clipboard@^5.0.1:
|
||||||
copy-to-clipboard "^3"
|
copy-to-clipboard "^3"
|
||||||
prop-types "^15.5.8"
|
prop-types "^15.5.8"
|
||||||
|
|
||||||
|
react-cropper@^1.0.1:
|
||||||
|
version "1.0.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-cropper/-/react-cropper-1.0.1.tgz#6e5595abb8576088ab3e51ecfdcfe5f865d0340f"
|
||||||
|
dependencies:
|
||||||
|
cropperjs v1.0.0-rc.3
|
||||||
|
prop-types "^15.5.8"
|
||||||
|
|
||||||
react-dev-utils@6.0.0-next.3e165448:
|
react-dev-utils@6.0.0-next.3e165448:
|
||||||
version "6.0.0-next.3e165448"
|
version "6.0.0-next.3e165448"
|
||||||
resolved "https://registry.yarnpkg.com/react-dev-utils/-/react-dev-utils-6.0.0-next.3e165448.tgz#d573ed0ba692f6cee23166f99204e5761df0897c"
|
resolved "https://registry.yarnpkg.com/react-dev-utils/-/react-dev-utils-6.0.0-next.3e165448.tgz#d573ed0ba692f6cee23166f99204e5761df0897c"
|
||||||
|
|
Loading…
Reference in New Issue