Просмотр исходного кода

Merge pull request #1680 from weseek/feat/crop-profile-image

Feat/crop profile image
Yuki Takei 6 лет назад
Родитель
Сommit
e274f2d32b

+ 1 - 0
package.json

@@ -133,6 +133,7 @@
     "passport-local": "^1.0.0",
     "passport-saml": "^1.0.0",
     "passport-twitter": "^1.0.4",
+    "react-image-crop": "^8.3.0",
     "rimraf": "^3.0.0",
     "slack-node": "^0.1.8",
     "socket.io": "^2.0.3",

+ 130 - 0
src/client/js/components/Me/ImageCropModal.jsx

@@ -0,0 +1,130 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Modal from 'react-bootstrap/es/Modal';
+import Button from 'react-bootstrap/es/Button';
+import { withTranslation } from 'react-i18next';
+import ReactCrop from 'react-image-crop';
+import AppContainer from '../../services/AppContainer';
+import { createSubscribedElement } from '../UnstatedUtils';
+import 'react-image-crop/dist/ReactCrop.css';
+
+class ImageCropModal extends React.Component {
+
+  // demo: https://codesandbox.io/s/72py4jlll6
+  constructor(props) {
+    super();
+    this.state = {
+      crop: null,
+      imageRef: null,
+    };
+    this.onImageLoaded = this.onImageLoaded.bind(this);
+    this.onCropChange = this.onCropChange.bind(this);
+    this.getCroppedImg = this.getCroppedImg.bind(this);
+    this.crop = this.crop.bind(this);
+    this.reset = this.reset.bind(this);
+    this.imageRef = null;
+  }
+
+  onImageLoaded(image) {
+    this.setState({ imageRef: image }, () => this.reset());
+    return false; // Return false when setting crop state in here.
+  }
+
+  onCropChange(crop) {
+    this.setState({ crop });
+  }
+
+  async getCroppedImg(image, crop, fileName) {
+    const canvas = document.createElement('canvas');
+    const scaleX = image.naturalWidth / image.width;
+    const scaleY = image.naturalHeight / image.height;
+    canvas.width = crop.width;
+    canvas.height = crop.height;
+    const ctx = canvas.getContext('2d');
+    ctx.drawImage(image, crop.x * scaleX, crop.y * scaleY, crop.width * scaleX, crop.height * scaleY, 0, 0, crop.width, crop.height);
+    return new Promise((resolve, reject) => {
+      canvas.toBlob((blob) => {
+        if (!blob) {
+          reject(new Error('Canvas is empty'));
+          return;
+        }
+        blob.name = fileName;
+        window.URL.revokeObjectURL(this.fileUrl);
+        this.fileUrl = window.URL.createObjectURL(blob);
+        resolve(this.fileUrl);
+      }, 'image/jpeg');
+    });
+  }
+
+  async crop() {
+    // crop immages
+    if (this.state.imageRef && this.state.crop.width && this.state.crop.height) {
+      const croppedImageUrl = await this.getCroppedImg(this.state.imageRef, this.state.crop, '/images/icons/user');
+      this.props.onCropCompleted(croppedImageUrl);
+    }
+  }
+
+  reset() {
+    const size = Math.min(this.state.imageRef.width, this.state.imageRef.height);
+    this.setState({
+      crop: {
+        aspect: 1,
+        unit: 'px',
+        x: this.state.imageRef.width / 2 - size / 2,
+        y: this.state.imageRef.height / 2 - size / 2,
+        width: size,
+        height: size,
+      },
+    });
+  }
+
+  render() {
+    return (
+      <Modal show={this.props.show} onHide={this.props.onModalClose}>
+        <Modal.Header closeButton>
+          <Modal.Title>Image Crop</Modal.Title>
+        </Modal.Header>
+        <Modal.Body className="my-4">
+          <ReactCrop
+            circularCrop
+            src={this.props.src}
+            crop={this.state.crop}
+            onImageLoaded={this.onImageLoaded}
+            onChange={this.onCropChange}
+          />
+        </Modal.Body>
+        <Modal.Footer>
+          <div className="d-flex justify-content-between">
+            <Button bsStyle="danger" onClick={this.reset}>
+              Reset
+            </Button>
+            <div className="d-flex">
+              <Button bsStyle="default" onClick={this.props.onModalClose}>
+                Cancel
+              </Button>
+              <Button bsStyle="primary" onClick={this.crop}>
+                Crop
+              </Button>
+            </div>
+          </div>
+        </Modal.Footer>
+      </Modal>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const ProfileImageFormWrapper = (props) => {
+  return createSubscribedElement(ImageCropModal, props, [AppContainer]);
+};
+ImageCropModal.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  show: PropTypes.bool.isRequired,
+  src: PropTypes.string,
+  onModalClose: PropTypes.func.isRequired,
+  onCropCompleted: PropTypes.func.isRequired,
+};
+export default withTranslation()(ProfileImageFormWrapper);

+ 70 - 5
src/client/js/components/Me/ProfileImageSettings.jsx

@@ -9,11 +9,23 @@ import { createSubscribedElement } from '../UnstatedUtils';
 import AppContainer from '../../services/AppContainer';
 import PersonalContainer from '../../services/PersonalContainer';
 
+import ImageCropModal from './ImageCropModal';
+
 class ProfileImageSettings extends React.Component {
 
   constructor(appContainer) {
     super();
 
+    this.state = {
+      show: false,
+      src: null,
+    };
+
+    this.imageRef = null;
+    this.onSelectFile = this.onSelectFile.bind(this);
+    this.hideModal = this.hideModal.bind(this);
+    this.cancelModal = this.cancelModal.bind(this);
+    this.onCropCompleted = this.onCropCompleted.bind(this);
     this.onClickSubmit = this.onClickSubmit.bind(this);
   }
 
@@ -35,8 +47,43 @@ class ProfileImageSettings extends React.Component {
     return `https://gravatar.com/avatar/${hash}`;
   }
 
+  onSelectFile(e) {
+    if (e.target.files && e.target.files.length > 0) {
+      const reader = new FileReader();
+      reader.addEventListener('load', () => this.setState({ src: reader.result }));
+      reader.readAsDataURL(e.target.files[0]);
+      this.setState({ show: true });
+    }
+  }
+
+  async onCropCompleted(croppedImageUrl) {
+    const { t, personalContainer } = this.props;
+    personalContainer.setState({ croppedImageUrl });
+    try {
+      await personalContainer.uploadAttachment(croppedImageUrl);
+      toastSuccess(t('toaster.update_successed', { target: t('Upload Image') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+    this.hideModal();
+  }
+
+  showModal() {
+    this.setState({ show: true });
+  }
+
+  hideModal() {
+    this.setState({ show: false });
+  }
+
+  cancelModal() {
+    this.hideModal();
+  }
+
   render() {
     const { t, personalContainer } = this.props;
+    const { uploadedPictureSrc, isGravatarEnabled } = personalContainer.state;
 
     return (
       <React.Fragment>
@@ -49,7 +96,7 @@ class ProfileImageSettings extends React.Component {
                   id="radioGravatar"
                   form="formImageType"
                   name="imagetypeForm[isGravatarEnabled]"
-                  checked={personalContainer.state.isGravatarEnabled}
+                  checked={isGravatarEnabled}
                   onChange={() => { personalContainer.changeIsGravatarEnabled(true) }}
                 />
                 <label htmlFor="radioGravatar">
@@ -72,7 +119,7 @@ class ProfileImageSettings extends React.Component {
                   id="radioUploadPicture"
                   form="formImageType"
                   name="imagetypeForm[isGravatarEnabled]"
-                  checked={!personalContainer.state.isGravatarEnabled}
+                  checked={!isGravatarEnabled}
                   onChange={() => { personalContainer.changeIsGravatarEnabled(false) }}
                 />
                 <label htmlFor="radioUploadPicture">
@@ -80,16 +127,34 @@ class ProfileImageSettings extends React.Component {
                 </label>
               </div>
             </h4>
-            <div className="form-group">
-              <div id="pictureUploadFormMessage"></div>
+            <div className="row mb-3">
               <label className="col-sm-4 control-label">
                 { t('Current Image') }
               </label>
-              {/* TDOO GW-1198 uproad profile image */}
+              <div className="col-sm-8">
+                {uploadedPictureSrc && (<p><img src={uploadedPictureSrc} className="picture picture-lg img-circle" id="settingUserPicture" /></p>)}
+                {/* TODO GW-1218 create apiV3 for delete image */}
+                <button type="button" className="btn btn-danger">{ t('Delete Image') }</button>
+              </div>
+            </div>
+            <div className="row">
+              <label className="col-sm-4 control-label">
+                {t('Upload new image')}
+              </label>
+              <div className="col-sm-8">
+                <input type="file" onChange={this.onSelectFile} name="profileImage" accept="image/*" />
+              </div>
             </div>
           </div>
         </div>
 
+        <ImageCropModal
+          show={this.state.show}
+          src={this.state.src}
+          onModalClose={this.cancelModal}
+          onCropCompleted={this.onCropCompleted}
+        />
+
         <div className="row my-3">
           <div className="col-xs-offset-4 col-xs-5">
             <button type="button" className="btn btn-primary" onClick={this.onClickSubmit} disabled={personalContainer.state.retrieveError != null}>

+ 29 - 1
src/client/js/services/PersonalContainer.js

@@ -24,6 +24,7 @@ export default class PersonalContainer extends Container {
       isEmailPublished: false,
       lang: 'en-US',
       isGravatarEnabled: false,
+      uploadedPictureSrc: this.getUploadedPictureSrc(this.appContainer.currentUser),
       externalAccounts: [],
       isPasswordSet: false,
       apiToken: '',
@@ -45,7 +46,6 @@ export default class PersonalContainer extends Container {
     try {
       const response = await this.appContainer.apiv3.get('/personal-setting/');
       const { currentUser } = response.data;
-
       this.setState({
         name: currentUser.name,
         email: currentUser.email,
@@ -63,6 +63,20 @@ export default class PersonalContainer extends Container {
     }
   }
 
+  /**
+   * define a function for uploaded picture
+   */
+  getUploadedPictureSrc(user) {
+    if (user.image) {
+      return user.image;
+    }
+    if (user.imageAttachment != null) {
+      return user.imageAttachment.filePathProxied;
+    }
+
+    return '/images/icons/user.svg';
+  }
+
   /**
    * retrieve external accounts that linked me
    */
@@ -165,4 +179,18 @@ export default class PersonalContainer extends Container {
     }
   }
 
+  /**
+   * Upload image
+   */
+  async uploadAttachment() {
+    try {
+      // TODO GW-1218 create api
+    }
+    catch (err) {
+      this.setState({ retrieveError: err });
+      logger.error(err);
+      throw new Error('Failed to upload profile image');
+    }
+  }
+
 }

+ 1 - 16
src/server/views/me/index.html

@@ -166,22 +166,7 @@
         </div><!-- /.form-group -->
 
         <div class="form-group">
-          <label for="" class="col-sm-4 control-label">
-            {{ t('Upload new image') }}
-          </label>
-          <div class="col-sm-8">
-            {% if fileUploadService.getIsUploadable() %}
-            <form action="/_api/attachments.uploadProfileImage" id="pictureUploadForm" method="post" class="form-horizontal" role="form">
-              <input type="hidden" name="_csrf" value="{{ csrf() }}">
-              <input type="file" name="profileImage" accept="image/*">
-              <div id="pictureUploadFormProgress" class="d-flex align-items-center">
-              </div>
-            </form>
-            {% else %}
-            * {{ t('page_me.form_help.profile_image1') }}<br>
-            * {{ t('page_me.form_help.profile_image2') }}<br>
-            {% endif %}
-          </div>
+          <div id="profile-image-uploader"></div>
         </div><!-- /.form-group -->
 
       </div><!-- /.col-sm- -->

+ 14 - 0
yarn.lock

@@ -3300,6 +3300,11 @@ clone@^2.1.0:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.1.tgz#d217d1e961118e3ac9a4b8bba3285553bf647cdb"
 
+clsx@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.0.4.tgz#0c0171f6d5cb2fe83848463c15fcc26b4df8c2ec"
+  integrity sha512-1mQ557MIZTrL/140j+JVdRM6e31/OA4vTYxXgqIIZlndyfjHpyawKZia1Im05Vp9BWmImkcNrNtFYQMyFcgJDg==
+
 co@^4.6.0:
   version "4.6.0"
   resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
@@ -11116,6 +11121,15 @@ react-i18next@^11.1.0:
     "@babel/runtime" "^7.3.1"
     html-parse-stringify2 "2.0.1"
 
+react-image-crop@^8.3.0:
+  version "8.3.0"
+  resolved "https://registry.yarnpkg.com/react-image-crop/-/react-image-crop-8.3.0.tgz#a0642dd3daafd77f142bac01887628cb967876b7"
+  integrity sha512-iC6Soqkf588WvEHc4EpKWNaiw4YQe0UbXziBpC8KRPKyaccakmWf7MewDFnYiPfPNEWxj96S390q7BUJz8LGZg==
+  dependencies:
+    clsx "^1.0.4"
+    core-js "^3.2.1"
+    prop-types "^15.7.2"
+
 react-is@^16.12.0:
   version "16.12.0"
   resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.12.0.tgz#2cc0fe0fba742d97fd527c42a13bec4eeb06241c"