ProfileImageSettings.tsx 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
  1. import React, { useCallback, useState, type JSX } from 'react';
  2. import { isPopulated } from '@growi/core';
  3. import { useTranslation } from 'next-i18next';
  4. import ImageCropModal from '~/client/components/Common/ImageCropModal';
  5. import { apiPost, apiPostForm } from '~/client/util/apiv1-client';
  6. import { apiv3Put } from '~/client/util/apiv3-client';
  7. import { toastSuccess, toastError } from '~/client/util/toastr';
  8. import { useCurrentUser } from '~/stores-universal/context';
  9. import { generateGravatarSrc, GRAVATAR_DEFAULT } from '~/utils/gravatar';
  10. const DEFAULT_IMAGE = '/images/icons/user.svg';
  11. const ProfileImageSettings = (): JSX.Element => {
  12. const { t } = useTranslation();
  13. const { data: currentUser } = useCurrentUser();
  14. const [isGravatarEnabled, setGravatarEnabled] = useState(currentUser?.isGravatarEnabled);
  15. const [uploadedPictureSrc, setUploadedPictureSrc] = useState(() => {
  16. if (currentUser?.imageAttachment != null && isPopulated(currentUser.imageAttachment)) {
  17. return currentUser.imageAttachment.filePathProxied ?? currentUser.image;
  18. }
  19. return currentUser?.image;
  20. });
  21. const [showImageCropModal, setShowImageCropModal] = useState(false);
  22. const [imageCropSrc, setImageCropSrc] = useState<string|ArrayBuffer|null>(null);
  23. const selectFileHandler = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
  24. if (e.target.files == null || e.target.files.length === 0) {
  25. return;
  26. }
  27. const reader = new FileReader();
  28. reader.addEventListener('load', () => setImageCropSrc(reader.result));
  29. reader.readAsDataURL(e.target.files[0]);
  30. setShowImageCropModal(true);
  31. }, []);
  32. const processImageCompletedHandler = useCallback(async(croppedImage) => {
  33. try {
  34. const formData = new FormData();
  35. formData.append('file', croppedImage);
  36. const response = await apiPostForm('/attachments.uploadProfileImage', formData);
  37. toastSuccess(t('toaster.update_successed', { target: t('Current Image'), ns: 'commons' }));
  38. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  39. setUploadedPictureSrc((response as any).attachment.filePathProxied);
  40. }
  41. catch (err) {
  42. toastError(err);
  43. }
  44. }, [t]);
  45. const deleteImageHandler = useCallback(async() => {
  46. try {
  47. await apiPost('/attachments.removeProfileImage');
  48. setUploadedPictureSrc(undefined);
  49. toastSuccess(t('toaster.update_successed', { target: t('Current Image'), ns: 'commons' }));
  50. }
  51. catch (err) {
  52. toastError(err);
  53. }
  54. }, [t]);
  55. const submit = useCallback(async() => {
  56. try {
  57. const response = await apiv3Put('/personal-setting/image-type', { isGravatarEnabled });
  58. const { userData } = response.data;
  59. setGravatarEnabled(userData.isGravatarEnabled);
  60. toastSuccess(t('toaster.update_successed', { target: t('Set Profile Image'), ns: 'commons' }));
  61. }
  62. catch (err) {
  63. toastError(err);
  64. }
  65. }, [isGravatarEnabled, t]);
  66. if (currentUser == null) {
  67. return <></>;
  68. }
  69. return (
  70. <>
  71. <div className="row justify-content-around mt-5 mt-md-4">
  72. <div className="col-md-3">
  73. <h5>
  74. <div className="form-check radio-primary">
  75. <input
  76. type="radio"
  77. id="radioGravatar"
  78. className="form-check-input"
  79. form="formImageType"
  80. name="imagetypeForm[isGravatarEnabled]"
  81. checked={isGravatarEnabled}
  82. onChange={() => setGravatarEnabled(true)}
  83. />
  84. <label className="form-label form-check-label" htmlFor="radioGravatar">
  85. <img src={GRAVATAR_DEFAULT} className="me-1" data-vrt-blackout-profile /> Gravatar
  86. </label>
  87. <a href="https://gravatar.com/" target="_blank" rel="noopener noreferrer">
  88. <small><span className="material-symbols-outlined ms-2 text-secondary" aria-hidden="true">info</span></small>
  89. </a>
  90. </div>
  91. </h5>
  92. <img src={generateGravatarSrc(currentUser.email)} className="rounded-pill" width="64" height="64" data-vrt-blackout-profile />
  93. </div>
  94. <div className="col-md-7 mt-5 mt-md-0">
  95. <h5>
  96. <div className="form-check radio-primary">
  97. <input
  98. type="radio"
  99. id="radioUploadPicture"
  100. className="form-check-input"
  101. form="formImageType"
  102. name="imagetypeForm[isGravatarEnabled]"
  103. checked={!isGravatarEnabled}
  104. onChange={() => setGravatarEnabled(false)}
  105. />
  106. <label className="form-label form-check-label" htmlFor="radioUploadPicture">
  107. { t('Upload Image') }
  108. </label>
  109. </div>
  110. </h5>
  111. <div className="row mt-3">
  112. <label className="col-md-6 col-lg-4 col-form-label text-start">
  113. { t('Current Image') }
  114. </label>
  115. <div className="col-md-6 col-lg-8">
  116. <p className="mb-0">
  117. <img src={uploadedPictureSrc ?? DEFAULT_IMAGE} width="64" height="64" className="rounded-circle" id="settingUserPicture" />
  118. </p>
  119. {uploadedPictureSrc && <button type="button" className="btn btn-danger mt-2" onClick={deleteImageHandler}>{ t('Delete Image') }</button>}
  120. </div>
  121. </div>
  122. <div className="row align-items-center mt-3 mt-md-5">
  123. <label className="col-md-6 col-lg-4 col-form-label text-start mt-3 mt-md-0">
  124. {t('Upload new image')}
  125. </label>
  126. <div className="col-md-6 col-lg-8">
  127. <input
  128. type="file"
  129. onChange={selectFileHandler}
  130. name="profileImage"
  131. accept="image/png,image/jpeg,image/jpg,image/gif,image/webp,image/avif,image/heic,image/heif,image/tiff,image/svg+xml"
  132. />
  133. </div>
  134. </div>
  135. </div>
  136. </div>
  137. <ImageCropModal
  138. isShow={showImageCropModal}
  139. src={imageCropSrc}
  140. onModalClose={() => setShowImageCropModal(false)}
  141. onImageProcessCompleted={processImageCompletedHandler}
  142. isCircular
  143. showCropOption
  144. />
  145. <div className="row mt-4">
  146. <div className="offset-4 col-5">
  147. <button type="button" className="btn btn-primary" onClick={submit}>
  148. {t('Update')}
  149. </button>
  150. </div>
  151. </div>
  152. </>
  153. );
  154. };
  155. export default ProfileImageSettings;