diff --git a/app/components/Editor/changes.js b/app/components/Editor/changes.js index 8e96d78f..b44c128c 100644 --- a/app/components/Editor/changes.js +++ b/app/components/Editor/changes.js @@ -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; diff --git a/app/scenes/Settings/Settings.js b/app/scenes/Settings/Settings.js index 8f53e749..c5fa7427 100644 --- a/app/scenes/Settings/Settings.js +++ b/app/scenes/Settings/Settings.js @@ -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 ( @@ -75,14 +86,19 @@ class Settings extends Component { Profile picture - - - Upload new image - + + + + Upload new image + +
- , + 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 ( + + + + (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} + /> + + + {this.isUploading && } + + Crop avatar + + + + ); + } + + render() { + if (this.isCropping) { + return this.renderCropping(); + } else { + return ( + + {this.props.children} + + ); + } + } +} + +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; diff --git a/app/utils/uploadFile.js b/app/utils/uploadFile.js index 7b53e39e..f88d9f80 100644 --- a/app/utils/uploadFile.js +++ b/app/utils/uploadFile.js @@ -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; +}; diff --git a/package.json b/package.json index 66d70236..948c57de 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server/api/user.js b/server/api/user.js index ccc3a487..e6f72fb5 100644 --- a/server/api/user.js +++ b/server/api/user.js @@ -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, }, }, diff --git a/yarn.lock b/yarn.lock index 359840fa..93444126 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"