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

Merge pull request #5692 from weseek/feat/7771-create-component-for-logo-upload

feat: gw 7771 create component for logo upload
Mudana-Grune 4 лет назад
Родитель
Сommit
f566a65ec3

+ 39 - 0
packages/app/src/client/services/AdminCustomizeContainer.js

@@ -7,6 +7,7 @@ import { toastError } from '../util/apiNotification';
 // eslint-disable-next-line no-unused-vars
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:services:AdminCustomizeContainer');
 const logger = loggerFactory('growi:services:AdminCustomizeContainer');
 
 
+const DEFAULT_LOGO = '/images/logo.svg';
 /**
 /**
  * Service container for admin customize setting page (Customize.jsx)
  * Service container for admin customize setting page (Customize.jsx)
  * @extends {Container} unstated Container
  * @extends {Container} unstated Container
@@ -55,12 +56,18 @@ export default class AdminCustomizeContainer extends Container {
         'tomorrow-night':   { name: '[Dark] Tomorrow Night',  border: false },
         'tomorrow-night':   { name: '[Dark] Tomorrow Night',  border: false },
         'vs2015':           { name: '[Dark] Vs 2015',         border: false },
         'vs2015':           { name: '[Dark] Vs 2015',         border: false },
       },
       },
+      uploadedLogoSrc: this.getUploadedLogoSrc(),
+      isUploadedLogo: false,
+      defaultLogoSrc: DEFAULT_LOGO,
       /* eslint-enable quote-props, no-multi-spaces */
       /* eslint-enable quote-props, no-multi-spaces */
     };
     };
     this.switchPageListLimitationS = this.switchPageListLimitationS.bind(this);
     this.switchPageListLimitationS = this.switchPageListLimitationS.bind(this);
     this.switchPageListLimitationM = this.switchPageListLimitationM.bind(this);
     this.switchPageListLimitationM = this.switchPageListLimitationM.bind(this);
     this.switchPageListLimitationL = this.switchPageListLimitationL.bind(this);
     this.switchPageListLimitationL = this.switchPageListLimitationL.bind(this);
     this.switchPageListLimitationXL = this.switchPageListLimitationXL.bind(this);
     this.switchPageListLimitationXL = this.switchPageListLimitationXL.bind(this);
+    this.getUploadedLogoSrc = this.getUploadedLogoSrc.bind(this);
+    this.deleteLogo = this.deleteLogo.bind(this);
+    this.uploadAttachment = this.uploadAttachment.bind(this);
 
 
   }
   }
 
 
@@ -429,4 +436,36 @@ export default class AdminCustomizeContainer extends Container {
     }
     }
   }
   }
 
 
+  getUploadedLogoSrc() {
+    this.setState({ isUploadedLogo: false });
+    return DEFAULT_LOGO;
+  }
+
+  async deleteLogo() {
+    try {
+      // await this.appContainer.apiPost('/attachments.removeProfileImage', { _csrf: this.appContainer.csrfToken });
+      this.setState({ isUploadedLogo: false, uploadedLogoSrc: DEFAULT_LOGO });
+    }
+    catch (err) {
+      this.setState({ retrieveError: err });
+      logger.error(err);
+      throw new Error('Failed to delete profile image');
+    }
+  }
+
+  async uploadAttachment(file) {
+    try {
+      const formData = new FormData();
+      formData.append('file', file);
+      formData.append('_csrf', this.appContainer.csrfToken);
+      // const response = await this.appContainer.apiPost('/attachments.uploadProfileImage', formData);
+      // this.setState({ isUploadedLogo: true, uploadedLogoSrc: response.attachment.filePathProxied });
+    }
+    catch (err) {
+      this.setState({ retrieveError: err });
+      logger.error(err);
+      throw new Error('Failed to upload profile image');
+    }
+  }
+
 }
 }

+ 128 - 0
packages/app/src/components/Admin/Customize/CropLogoModal.jsx

@@ -0,0 +1,128 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import canvasToBlob from 'async-canvas-to-blob';
+
+import {
+  Modal,
+  ModalHeader,
+  ModalBody,
+  ModalFooter,
+} from 'reactstrap';
+import { withTranslation } from 'react-i18next';
+import ReactCrop from 'react-image-crop';
+import loggerFactory from '~/utils/logger';
+import AppContainer from '~/client/services/AppContainer';
+import { withUnstatedContainers } from '../../UnstatedUtils';
+import 'react-image-crop/dist/ReactCrop.css';
+import { toastError } from '~/client/util/apiNotification';
+
+const logger = loggerFactory('growi:ImageCropModal');
+
+class CropLogoModal 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;
+  }
+
+  componentDidMount() {
+    document.body.style.position = 'static';
+  }
+
+  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);
+    try {
+      const blob = await canvasToBlob(canvas);
+      return blob;
+    }
+    catch (err) {
+      logger.error(err);
+      toastError(new Error('Failed to draw image'));
+    }
+  }
+
+  async crop() {
+    // crop immages
+    if (this.state.imageRef && this.state.crop.width && this.state.crop.height) {
+      const croppedImage = await this.getCroppedImg(this.state.imageRef, this.state.crop, '/images/icons/user');
+      this.props.onCropCompleted(croppedImage);
+    }
+  }
+
+  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 isOpen={this.props.show} toggle={this.props.onModalClose}>
+        <ModalHeader tag="h4" toggle={this.props.onModalClose} className="bg-info text-light">
+          Image Crop
+        </ModalHeader>
+        <ModalBody className="my-4">
+          <ReactCrop src={this.props.src} crop={this.state.crop} onImageLoaded={this.onImageLoaded} onChange={this.onCropChange} />
+        </ModalBody>
+        <ModalFooter>
+          <button type="button" className="btn btn-outline-danger rounded-pill mr-auto" onClick={this.reset}>
+            Reset
+          </button>
+          <button type="button" className="btn btn-outline-secondary rounded-pill mr-2" onClick={this.props.onModalClose}>
+            Cancel
+          </button>
+          <button type="button" className="btn btn-outline-primary rounded-pill" onClick={this.crop}>
+            Crop
+          </button>
+        </ModalFooter>
+      </Modal>
+    );
+  }
+
+}
+
+/**
+ * Wrapper component for using unstated
+ */
+const LogoFormWrapper = withUnstatedContainers(CropLogoModal, [AppContainer]);
+CropLogoModal.propTypes = {
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  show: PropTypes.bool.isRequired,
+  src: PropTypes.string,
+  onModalClose: PropTypes.func.isRequired,
+  onCropCompleted: PropTypes.func.isRequired,
+};
+export default withTranslation()(LogoFormWrapper);

+ 4 - 0
packages/app/src/components/Admin/Customize/Customize.jsx

@@ -19,6 +19,7 @@ import CustomizeCssSetting from './CustomizeCssSetting';
 import CustomizeScriptSetting from './CustomizeScriptSetting';
 import CustomizeScriptSetting from './CustomizeScriptSetting';
 import CustomizeHeaderSetting from './CustomizeHeaderSetting';
 import CustomizeHeaderSetting from './CustomizeHeaderSetting';
 import CustomizeTitle from './CustomizeTitle';
 import CustomizeTitle from './CustomizeTitle';
+import CustomizeLogoSetting from './CustomizeLogoSetting';
 
 
 const logger = loggerFactory('growi:services:AdminCustomizePage');
 const logger = loggerFactory('growi:services:AdminCustomizePage');
 
 
@@ -71,6 +72,9 @@ function Customize(props) {
       <div className="mb-5">
       <div className="mb-5">
         <CustomizeScriptSetting />
         <CustomizeScriptSetting />
       </div>
       </div>
+      <div className="mb-5">
+        <CustomizeLogoSetting />
+      </div>
     </div>
     </div>
   );
   );
 }
 }

+ 171 - 0
packages/app/src/components/Admin/Customize/CustomizeLogoSetting.jsx

@@ -0,0 +1,171 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { withTranslation } from 'react-i18next';
+
+import { withUnstatedContainers } from '../../UnstatedUtils';
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+
+import AppContainer from '~/client/services/AppContainer';
+
+import AdminCustomizeContainer from '~/client/services/AdminCustomizeContainer';
+import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
+import CropLogoModal from './CropLogoModal';
+
+class CustomizeLogoSetting extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      show: false,
+      src: null,
+      croppedImage: null,
+    };
+
+    // this.imageRef = null;
+    this.onSelectFile = this.onSelectFile.bind(this);
+    this.onClickDeleteBtn = this.onClickDeleteBtn.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);
+  }
+
+  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 onClickSubmit() {
+    const { t, adminCustomizeContainer } = this.props;
+
+    try {
+      await adminCustomizeContainer.uploadAttachment(this.state.croppedImage);
+      toastSuccess(t('toaster.update_successed', { target: t('admin:customize_setting.custom_script') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+  async onClickDeleteBtn() {
+    const { t, adminCustomizeContainer } = this.props;
+    try {
+      await adminCustomizeContainer.deleteLogo();
+      toastSuccess(t('toaster.update_successed', { target: t('Current Image') }));
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }
+
+  async onCropCompleted(croppedImage) {
+    this.setState({ croppedImage });
+    this.hideModal();
+  }
+
+
+  hideModal() {
+    this.setState({ show: false });
+  }
+
+  cancelModal() {
+    this.hideModal();
+  }
+
+  render() {
+    const { t, adminCustomizeContainer } = this.props;
+    const {
+      uploadedLogoSrc, isUploadedLogo, isDefaultLogo, defaultLogoSrc,
+    } = adminCustomizeContainer.state;
+
+    return (
+      <React.Fragment>
+        <div className="row">
+          <div className="col-12">
+            <h2 className="admin-setting-header">{t('admin:customize_setting.custom_script')}</h2>
+            <div className="row">
+              <div className="col-md-6 col-12">
+                <h4>
+                  <div className="custom-control custom-radio radio-primary">
+                    <input
+                      type="radio"
+                      id="radioDefaultLogo"
+                      className="custom-control-input"
+                      form="formImageType"
+                      name="imagetypeForm[isDefaultLogo]"
+                      checked={isDefaultLogo}
+                      onChange={() => { adminCustomizeContainer.changeIsDefaultLogoEnabled(true) }}
+                    />
+                    <label className="custom-control-label" htmlFor="radioDefaultLogo">
+                      Default Logo
+                    </label>
+                  </div>
+                </h4>
+                <img src={defaultLogoSrc} width="64" />
+              </div>
+              <div className="col-md-6 col-12">
+                <h4>
+                  <div className="custom-control custom-radio radio-primary">
+                    <input
+                      type="radio"
+                      id="radioUploadLogo"
+                      className="custom-control-input"
+                      form="formImageType"
+                      name="imagetypeForm[isDefaultLogo]"
+                      checked={!isDefaultLogo}
+                      onChange={() => { adminCustomizeContainer.changeIsDefaultLogoEnabled(false) }}
+                    />
+                    <label className="custom-control-label" htmlFor="radioUploadLogo">
+                      { t('Upload Logo') }
+                    </label>
+                  </div>
+                </h4>
+                <div className="row mb-3">
+                  <label className="col-sm-4 col-12 col-form-label text-left">
+                    { t('Current Logo') }
+                  </label>
+                  <div className="col-sm-8 col-12">
+                    {uploadedLogoSrc && (<p><img src={uploadedLogoSrc} className="picture picture-lg " id="settingBrandLogo" width="64" /></p>)}
+                    {isUploadedLogo && <button type="button" className="btn btn-danger" onClick={this.onClickDeleteBtn}>{ t('Delete Logo') }</button>}
+                  </div>
+                </div>
+                <div className="row">
+                  <label className="col-sm-4 col-12 col-form-label text-left">
+                    {t('Upload new logo')}
+                  </label>
+                  <div className="col-sm-8 col-12">
+                    <input type="file" onChange={this.onSelectFile} name="brandLogo" accept="image/*" />
+                  </div>
+                </div>
+              </div>
+            </div>
+            <AdminUpdateButtonRow onClick={this.onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />
+          </div>
+        </div>
+
+        <CropLogoModal
+          show={this.state.show}
+          src={this.state.src}
+          onModalClose={this.cancelModal}
+          onCropCompleted={this.onCropCompleted}
+        />
+      </React.Fragment>
+    );
+  }
+
+}
+
+const CustomizeLogoSettingWrapper = withUnstatedContainers(CustomizeLogoSetting, [AppContainer, AdminCustomizeContainer]);
+
+CustomizeLogoSetting.propTypes = {
+  t: PropTypes.func.isRequired, // i18next
+  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  adminCustomizeContainer: PropTypes.instanceOf(AdminCustomizeContainer).isRequired,
+};
+
+export default withTranslation()(CustomizeLogoSettingWrapper);