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"
|
||||
# for ropsten use the following
|
||||
# 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"
|
||||
ETHEREUM_PROVIDER = "http"
|
||||
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 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.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
|
||||
|
||||
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)
|
||||
if sig_address.lower() != account_address.lower():
|
||||
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,
|
||||
account_address=account_address
|
||||
)
|
||||
}, 400
|
||||
)
|
||||
}, 400
|
||||
except BadSignatureException:
|
||||
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)
|
||||
if sig_address.lower() != account_address.lower():
|
||||
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,
|
||||
account_address=account_address
|
||||
)
|
||||
}, 400
|
||||
)
|
||||
}, 400
|
||||
except BadSignatureException:
|
||||
return {"message": "Invalid message signature"}, 400
|
||||
|
||||
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"])
|
||||
@requires_sm
|
||||
@requires_same_user_auth
|
||||
|
@ -140,6 +174,7 @@ def update_user(user_identity, display_name, title, social_medias, avatar):
|
|||
else:
|
||||
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:
|
||||
Avatar.query.filter_by(user_id=user.id).delete()
|
||||
avatar_link = avatar.get('link')
|
||||
|
@ -149,6 +184,11 @@ def update_user(user_identity, display_name, title, social_medias, avatar):
|
|||
else:
|
||||
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()
|
||||
result = user_schema.dump(user)
|
||||
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;
|
||||
left: 0;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
z-index: 1000;
|
||||
z-index: 900;
|
||||
}
|
||||
|
||||
.ProfileEdit {
|
||||
position: relative;
|
||||
z-index: 1001;
|
||||
z-index: 901;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
|
@ -28,77 +28,6 @@
|
|||
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 {
|
||||
flex: 1;
|
||||
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import React from 'react';
|
||||
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_TYPE, TeamMember } from 'types';
|
||||
import { UserState } from 'modules/users/reducers';
|
||||
import { getCreateTeamMemberError } from 'modules/create/utils';
|
||||
import UserAvatar from 'components/UserAvatar';
|
||||
import AvatarEdit from './AvatarEdit';
|
||||
import './ProfileEdit.less';
|
||||
|
||||
interface Props {
|
||||
|
@ -54,27 +55,12 @@ export default class ProfileEdit extends React.PureComponent<Props, State> {
|
|||
return (
|
||||
<>
|
||||
<div className="ProfileEdit">
|
||||
<div className="ProfileEdit-avatar">
|
||||
<UserAvatar className="ProfileEdit-avatar-img" user={fields} />
|
||||
<Button
|
||||
className="ProfileEdit-avatar-change"
|
||||
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>
|
||||
<AvatarEdit
|
||||
user={fields}
|
||||
onDone={this.handleChangePhoto}
|
||||
onDelete={this.handleDeletePhoto}
|
||||
/>
|
||||
|
||||
<div className="ProfileEdit-info">
|
||||
<Form
|
||||
className="ProfileEdit-info-form"
|
||||
|
@ -187,6 +173,13 @@ export default class ProfileEdit extends React.PureComponent<Props, State> {
|
|||
};
|
||||
|
||||
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();
|
||||
};
|
||||
|
||||
|
@ -226,13 +219,10 @@ export default class ProfileEdit extends React.PureComponent<Props, State> {
|
|||
});
|
||||
};
|
||||
|
||||
private handleChangePhoto = () => {
|
||||
// TODO: Actual file uploading
|
||||
const gender = ['men', 'women'][Math.floor(Math.random() * 2)];
|
||||
const num = Math.floor(Math.random() * 80);
|
||||
private handleChangePhoto = (url: string) => {
|
||||
const fields = {
|
||||
...this.state.fields,
|
||||
avatarUrl: `https://randomuser.me/api/portraits/${gender}/${num}.jpg`,
|
||||
avatarUrl: url,
|
||||
};
|
||||
const isChanged = this.isChangedCheck(fields);
|
||||
this.setState({
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import types from './types';
|
||||
import usersTypes from 'modules/users/types';
|
||||
// TODO: Use a common User type instead of this
|
||||
import { TeamMember, AuthSignatureData } from 'types';
|
||||
|
||||
|
@ -56,6 +57,14 @@ export default function createReducer(
|
|||
authSignatureAddress: action.payload.user.ethAddress,
|
||||
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:
|
||||
return {
|
||||
...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/numeral": "^0.0.25",
|
||||
"@types/react": "16.4.18",
|
||||
"@types/react-cropper": "^0.10.3",
|
||||
"@types/react-dom": "16.0.9",
|
||||
"@types/react-helmet": "^5.0.7",
|
||||
"@types/react-redux": "^6.0.2",
|
||||
|
@ -118,6 +119,7 @@
|
|||
"prettier-package-json": "^1.6.0",
|
||||
"query-string": "6.1.0",
|
||||
"react": "16.5.2",
|
||||
"react-cropper": "^1.0.1",
|
||||
"react-dev-utils": "^5.0.2",
|
||||
"react-dom": "16.5.2",
|
||||
"react-helmet": "^5.2.0",
|
||||
|
|
|
@ -1858,6 +1858,10 @@
|
|||
dependencies:
|
||||
"@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":
|
||||
version "4.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/dotenv/-/dotenv-4.0.3.tgz#ebcfc40da7bc0728b705945b7db48485ec5b4b67"
|
||||
|
@ -1967,6 +1971,13 @@
|
|||
version "1.2.2"
|
||||
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":
|
||||
version "16.0.9"
|
||||
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"
|
||||
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:
|
||||
version "5.2.0"
|
||||
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"
|
||||
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:
|
||||
version "6.0.0-next.3e165448"
|
||||
resolved "https://registry.yarnpkg.com/react-dev-utils/-/react-dev-utils-6.0.0-next.3e165448.tgz#d573ed0ba692f6cee23166f99204e5761df0897c"
|
||||
|
|
Loading…
Reference in New Issue