Avatar upload

This commit is contained in:
Jori Lallo 2017-12-13 23:17:08 -08:00
parent c2879c51b2
commit 7d756e4fae
7 changed files with 228 additions and 20 deletions

View File

@ -3,7 +3,7 @@ import { Change } from 'slate';
import { Editor } from 'slate-react';
import uuid from 'uuid';
import EditList from './plugins/EditList';
import uploadFile from 'utils/uploadFile';
import { uploadFile } from 'utils/uploadFile';
const { changes } = EditList;

View File

@ -9,6 +9,7 @@ import { color, size } from 'shared/styles/constants';
import { client } from 'utils/ApiClient';
import AuthStore from 'stores/AuthStore';
import ErrorsStore from 'stores/ErrorsStore';
import ImageUpload from './components/ImageUpload';
import Input, { LabelText } from 'components/Input';
import Button from 'components/Button';
import CenteredContent from 'components/CenteredContent';
@ -24,6 +25,7 @@ class Settings extends Component {
};
@observable name: string;
@observable avatarUrl: ?string;
@observable updated: boolean;
@observable isSaving: boolean;
@ -44,6 +46,7 @@ class Settings extends Component {
try {
const res = await client.post(`/user.update`, {
name: this.name,
avatarUrl: this.avatarUrl,
});
invariant(res && res.data, 'Document list not available');
const { data } = res;
@ -63,10 +66,18 @@ class Settings extends Component {
this.name = ev.target.value;
};
handleAvatarUpload = (avatarUrl: string) => {
this.avatarUrl = avatarUrl;
};
handleAvatarError = (error: ?string) => {
this.props.errors.add(error || 'Unable to upload new avatar');
};
render() {
const { user } = this.props.auth;
if (!user) return null;
const avatarUrl = user.avatarUrl;
const avatarUrl = this.avatarUrl || user.avatarUrl;
return (
<CenteredContent>
@ -75,14 +86,19 @@ class Settings extends Component {
<ProfilePicture column>
<LabelText>Profile picture</LabelText>
<AvatarContainer>
<Avatar src={avatarUrl} />
<Flex auto align="center" justify="center">
Upload new image
</Flex>
<ImageUpload
onSuccess={this.handleAvatarUpload}
onError={this.handleAvatarError}
>
<Avatar src={avatarUrl} />
<Flex auto align="center" justify="center">
Upload new image
</Flex>
</ImageUpload>
</AvatarContainer>
</ProfilePicture>
<form onSubmit={this.handleSubmit}>
<Input
<StyledInput
label="Name"
value={this.name}
onChange={this.handleNameChange}
@ -122,7 +138,7 @@ const AvatarContainer = styled(Flex)`
${avatarStyles};
position: relative;
div {
div div {
${avatarStyles};
position: absolute;
top: 0;
@ -145,4 +161,8 @@ const Avatar = styled.img`
${avatarStyles};
`;
const StyledInput = styled(Input)`
max-width: 350px;
`;
export default inject('auth', 'errors', 'auth')(Settings);

View File

@ -0,0 +1,143 @@
// @flow
import React, { Component } from 'react';
import { observable } from 'mobx';
import { observer } from 'mobx-react';
import styled from 'styled-components';
import Dropzone from 'react-dropzone';
import LoadingIndicator from 'components/LoadingIndicator';
import Flex from 'shared/components/Flex';
import Modal from 'components/Modal';
import Button from 'components/Button';
import AvatarEditor from 'react-avatar-editor';
import { uploadFile, dataUrlToBlob } from 'utils/uploadFile';
type Props = {
children?: React$Element<any>,
onSuccess: string => void,
onError: string => void,
};
@observer
class DropToImport extends Component {
@observable isUploading: boolean = false;
@observable isCropping: boolean = false;
@observable zoom: number = 1;
props: Props;
file: File;
avatarEditorRef: HTMLCanvasElement;
onDropAccepted = async (files: File[]) => {
this.isCropping = true;
this.file = files[0];
};
handleCrop = async () => {
// $FlowIssue getImage() exists
const canvas = this.avatarEditorRef.getImage();
const imageBlob = dataUrlToBlob(canvas.toDataURL());
try {
const asset = await uploadFile(imageBlob, { name: this.file.name });
this.props.onSuccess(asset.url);
} catch (err) {
this.props.onError('Unable to upload image');
} finally {
this.isUploading = false;
this.isCropping = false;
}
};
handleZoom = (event: SyntheticDragEvent) => {
let target = event.target;
if (target instanceof HTMLInputElement) {
this.zoom = parseFloat(target.value);
}
};
renderCropping() {
return (
<Modal isOpen title="">
<Flex auto column align="center" justify="center">
<AvatarEditorContainer>
<AvatarEditor
ref={ref => (this.avatarEditorRef = ref)}
image={this.file}
width={250}
height={250}
border={25}
borderRadius={150}
color={[255, 255, 255, 0.6]} // RGBA
scale={this.zoom}
rotate={0}
/>
</AvatarEditorContainer>
<RangeInput
type="range"
min="0.1"
max="2"
step="0.01"
defaultValue="1"
onChange={this.handleZoom}
/>
{this.isUploading && <LoadingIndicator />}
<CropButton onClick={this.handleCrop} disabled={this.isUploading}>
Crop avatar
</CropButton>
</Flex>
</Modal>
);
}
render() {
if (this.isCropping) {
return this.renderCropping();
} else {
return (
<Dropzone
accept="image/png, image/jpeg"
onDropAccepted={this.onDropAccepted}
style={{}}
disablePreview
{...this.props}
>
{this.props.children}
</Dropzone>
);
}
}
}
const AvatarEditorContainer = styled(Flex)`
margin-bottom: 30px;
`;
const RangeInput = styled.input`
display: block;
width: 300px;
margin-bottom: 30px;
height: 4px;
cursor: pointer;
color: inherit;
border-radius: 99999px;
background-color: #dee1e3;
appearance: none;
&::-webkit-slider-thumb {
-webkit-appearance: none;
height: 16px;
width: 16px;
border-radius: 50%;
background: black;
cursor: pointer;
}
&:focus {
outline: none;
}
`;
const CropButton = styled(Button)`
width: 300px;
`;
export default DropToImport;

View File

@ -6,15 +6,22 @@ type File = {
blob: boolean,
type: string,
size: number,
name: string,
name?: string,
file: string,
};
export default async function uploadFile(file: File) {
type Options = {
name?: string,
};
export const uploadFile = async (file: File | Blob, option?: Options) => {
// $FlowFixMe Blob makes life hard
const filename = (option && option.name) || file.name;
const response = await client.post('/user.s3Upload', {
kind: file.type,
size: file.size,
filename: file.name,
filename,
});
invariant(response, 'Response should be available');
@ -28,6 +35,7 @@ export default async function uploadFile(file: File) {
}
if (file.blob) {
// $FlowFixMe
formData.append('file', file.file);
} else {
// $FlowFixMe
@ -41,4 +49,14 @@ export default async function uploadFile(file: File) {
await fetch(data.uploadUrl, options);
return asset;
}
};
export const dataUrlToBlob = (dataURL: string) => {
var blobBin = atob(dataURL.split(',')[1]);
var array = [];
for (var i = 0; i < blobBin.length; i++) {
array.push(blobBin.charCodeAt(i));
}
const file = new Blob([new Uint8Array(array)], { type: 'image/png' });
return file;
};

View File

@ -145,10 +145,10 @@
"randomstring": "1.1.5",
"raw-loader": "^0.5.1",
"react": "^16.1.0",
"react-avatar-editor": "^10.3.0",
"react-dom": "^16.1.0",
"react-dropzone": "4.2.1",
"react-helmet": "^5.2.0",
"react-image-crop": "^3.0.7",
"react-keydown": "^1.7.3",
"react-markdown": "^3.0.2",
"react-medium-image-zoom": "^3.0.2",

View File

@ -1,7 +1,13 @@
// @flow
import uuid from 'uuid';
import Router from 'koa-router';
<<<<<<< HEAD
import { makePolicy, signPolicy, publicS3Endpoint } from '../utils/s3';
=======
import Event from '../models/Event';
import { makePolicy, signPolicy } from '../utils/s3';
>>>>>>> Avatar upload
import auth from './middlewares/authentication';
import { presentUser } from '../presenters';
@ -13,10 +19,16 @@ router.post('user.info', auth(), async ctx => {
router.post('user.update', auth(), async ctx => {
const { user } = ctx.state;
const { name } = ctx.body;
ctx.assertNotEmpty(name, "name can't be empty");
const { name, avatarUrl } = ctx.body;
if (name) user.name = name;
if (
avatarUrl &&
avatarUrl.startsWith(
`${process.env.AWS_S3_UPLOAD_BUCKET_URL}uploads/${ctx.state.user.id}`
)
)
user.avatarUrl = avatarUrl;
await user.save();
ctx.body = { data: await presentUser(ctx, user) };
@ -32,6 +44,19 @@ router.post('user.s3Upload', auth(), async ctx => {
const key = `uploads/${ctx.state.user.id}/${s3Key}/${filename}`;
const policy = makePolicy();
const endpoint = publicS3Endpoint();
const url = `${endpoint}/${key}`;
await Event.create({
name: 'user.s3Upload',
data: {
filename,
kind,
size,
url,
},
teamId: ctx.state.user.teamId,
userId: ctx.state.user.id,
});
ctx.body = {
data: {
@ -48,8 +73,8 @@ router.post('user.s3Upload', auth(), async ctx => {
},
asset: {
contentType: kind,
url: `${endpoint}/${key}`,
name: filename,
url,
size,
},
},

View File

@ -7379,6 +7379,12 @@ rc@^1.0.1, rc@^1.1.7:
minimist "^1.2.0"
strip-json-comments "~2.0.1"
react-avatar-editor@^10.3.0:
version "10.3.0"
resolved "https://registry.yarnpkg.com/react-avatar-editor/-/react-avatar-editor-10.3.0.tgz#7ee54774a274b3aa733d8651e6138d4904114c7d"
dependencies:
prop-types "^15.5.8"
react-create-component-from-tag-prop@^1.2.1:
version "1.3.1"
resolved "https://registry.npmjs.org/react-create-component-from-tag-prop/-/react-create-component-from-tag-prop-1.3.1.tgz#5389407d99f88ba2b36351780a6094470b44a7c7"
@ -7415,10 +7421,6 @@ react-helmet@^5.2.0:
prop-types "^15.5.4"
react-side-effect "^1.1.0"
react-image-crop@^3.0.7:
version "3.0.7"
resolved "https://registry.yarnpkg.com/react-image-crop/-/react-image-crop-3.0.7.tgz#8f7f1f03031c76c8b6306d11f7d1b8bf3007ab8c"
react-immutable-proptypes@^2.1.0:
version "2.1.0"
resolved "https://registry.npmjs.org/react-immutable-proptypes/-/react-immutable-proptypes-2.1.0.tgz#023d6f39bb15c97c071e9e60d00d136eac5fa0b4"