diff --git a/package-lock.json b/package-lock.json index 3adabce5..ba26f4db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.8.0", "license": "MIT", "dependencies": { + "cropperjs": "1.6.1", "form-data": "4.0.0", "i18next": "22.4.15", "i18next-icu": "2.3.0", @@ -7530,6 +7531,11 @@ "dev": true, "peer": true }, + "node_modules/cropperjs": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/cropperjs/-/cropperjs-1.6.1.tgz", + "integrity": "sha512-F4wsi+XkDHCOMrHMYjrTEE4QBOrsHHN5/2VsVAaRq8P7E5z7xQpT75S+f/9WikmBEailas3+yo+6zPIomW+NOA==" + }, "node_modules/cross-env": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", diff --git a/package.json b/package.json index 15dc67df..f7d5ac4b 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "typecheck:watch": "tsc --noEmit --watch" }, "dependencies": { + "cropperjs": "1.6.1", "form-data": "4.0.0", "i18next": "22.4.15", "i18next-icu": "2.3.0", diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index bfcc71cb..0bdd9653 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -105,6 +105,7 @@ "Create gallery": "Create gallery", "Create post": "Create post", "Create video": "Create video", + "Crop image": "Crop image", "Culture": "Culture", "Date of Birth": "Date of Birth", "Decline": "Decline", diff --git a/public/locales/ru/translation.json b/public/locales/ru/translation.json index 33067ba4..60151706 100644 --- a/public/locales/ru/translation.json +++ b/public/locales/ru/translation.json @@ -109,6 +109,7 @@ "Create gallery": "Создать галерею", "Create post": "Создать публикацию", "Create video": "Создать видео", + "Crop image": "Кадрировать изображение", "Culture": "Культура", "Date of Birth": "Дата рождения", "Decline": "Отмена", diff --git a/src/components/ProfileSettings/ProfileSettings.tsx b/src/components/ProfileSettings/ProfileSettings.tsx index 1cc18e4d..176a7fa8 100644 --- a/src/components/ProfileSettings/ProfileSettings.tsx +++ b/src/components/ProfileSettings/ProfileSettings.tsx @@ -14,13 +14,18 @@ import { getImageUrl } from '../../utils/getImageUrl' import { handleImageUpload } from '../../utils/handleImageUpload' import { profileSocialLinks } from '../../utils/profileSocialLinks' import { validateUrl } from '../../utils/validateUrl' + +import { Modal } from '../Nav/Modal' import { Button } from '../_shared/Button' import { Icon } from '../_shared/Icon' import { Loading } from '../_shared/Loading' import { Popover } from '../_shared/Popover' import { SocialNetworkInput } from '../_shared/SocialNetworkInput' +import { ImageCropper } from '../_shared/ImageCropper' import { ProfileSettingsNavigation } from '../Nav/ProfileSettingsNavigation' +import { showModal, hideModal } from '../../stores/ui' + import styles from '../../pages/profile/Settings.module.scss' const SimplifiedEditor = lazy(() => import('../../components/Editor/SimplifiedEditor')) @@ -28,12 +33,14 @@ const GrowingTextarea = lazy(() => import('../../components/_shared/GrowingTexta export const ProfileSettings = () => { const { t } = useLocalize() + const [prevForm, setPrevForm] = createStore({}) const [isFormInitialized, setIsFormInitialized] = createSignal(false) const [social, setSocial] = createSignal([]) const [addLinkForm, setAddLinkForm] = createSignal(false) const [incorrectUrl, setIncorrectUrl] = createSignal(false) const [isUserpicUpdating, setIsUserpicUpdating] = createSignal(false) + const [userpicFile, setUserpicFile] = createSignal(null) const [uploadError, setUploadError] = createSignal(false) const [isFloatingPanelVisible, setIsFloatingPanelVisible] = createSignal(false) const [hostname, setHostname] = createSignal(null) @@ -115,23 +122,32 @@ export const ProfileSettings = () => { } } - const { selectFiles } = createFileUploader({ multiple: false, accept: 'image/*' }) + const handleCropAvatar = () => { + const { selectFiles } = createFileUploader({ multiple: false, accept: 'image/*' }) - const handleUploadAvatar = async () => { - selectFiles(async ([uploadFile]) => { - try { - setUploadError(false) - setIsUserpicUpdating(true) - const result = await handleImageUpload(uploadFile) - updateFormField('userpic', result.url) - setIsUserpicUpdating(false) - } catch (error) { - setUploadError(true) - console.error('[upload avatar] error', error) - } + selectFiles(([uploadFile]) => { + setUserpicFile(uploadFile) + + showModal('cropImage') }) } + const handleUploadAvatar = async (uploadFile) => { + try { + setUploadError(false) + setIsUserpicUpdating(true) + + const result = await handleImageUpload(uploadFile) + updateFormField('userpic', result.url) + + setUserpicFile(null) + setIsUserpicUpdating(false) + } catch (error) { + setUploadError(true) + console.error('[upload avatar] error', error) + } + } + onMount(() => { setHostname(window?.location.host) @@ -178,7 +194,7 @@ export const ProfileSettings = () => {
@@ -206,17 +222,19 @@ export const ProfileSettings = () => { )} - + + {/* @@TODO inspect popover below. onClick causes page refreshing */} + {/* {(triggerRef: (el) => void) => ( )} - + */}
@@ -365,6 +383,21 @@ export const ProfileSettings = () => {
+ setUserpicFile(null)}> +

{t('Crop image')}

+ + + { + handleUploadAvatar(data) + + hideModal() + }} + onDecline={() => hideModal()} + /> + +
) diff --git a/src/components/_shared/ImageCropper/ImageCropper.module.scss b/src/components/_shared/ImageCropper/ImageCropper.module.scss new file mode 100644 index 00000000..51e4dd01 --- /dev/null +++ b/src/components/_shared/ImageCropper/ImageCropper.module.scss @@ -0,0 +1,10 @@ +.cropperContainer { + max-height: 55vh; +} + +.cropperControls { + display: flex; + justify-content: space-between; + + margin-top: 2rem; +} diff --git a/src/components/_shared/ImageCropper/ImageCropper.tsx b/src/components/_shared/ImageCropper/ImageCropper.tsx new file mode 100644 index 00000000..6e2646f8 --- /dev/null +++ b/src/components/_shared/ImageCropper/ImageCropper.tsx @@ -0,0 +1,79 @@ +import 'cropperjs/dist/cropper.css' + +import { createSignal, onMount, Show } from 'solid-js' +import Cropper from 'cropperjs' +import { UploadFile } from '@solid-primitives/upload' + +import { useLocalize } from '../../../context/localize' + +import { Button } from '../Button' + +import styles from './ImageCropper.module.scss' + +interface CropperProps { + uploadFile: UploadFile + onSave: (any) => void + onDecline?: () => void +} + +export const ImageCropper = (props: CropperProps) => { + const { t } = useLocalize() + + const imageTagRef: { current: HTMLImageElement } = { + current: null, + } + + const [cropper, setCropper] = createSignal(null) + + onMount(() => { + if (imageTagRef.current) { + setCropper( + new Cropper(imageTagRef.current, { + viewMode: 1, + aspectRatio: 1, + guides: false, + background: false, + rotatable: false, + autoCropArea: 1, + modal: true, + }), + ) + } + }) + + return ( +
+
+ (imageTagRef.current = el)} + src={props.uploadFile.source} + alt="image crop panel" + /> +
+ +
+ +
+
+ ) +} diff --git a/src/components/_shared/ImageCropper/index.tsx b/src/components/_shared/ImageCropper/index.tsx new file mode 100644 index 00000000..08e025d1 --- /dev/null +++ b/src/components/_shared/ImageCropper/index.tsx @@ -0,0 +1 @@ +export { ImageCropper } from './ImageCropper' diff --git a/src/stores/ui.ts b/src/stores/ui.ts index 14982c53..7201f915 100644 --- a/src/stores/ui.ts +++ b/src/stores/ui.ts @@ -26,6 +26,7 @@ export type ModalType = | 'search' | 'inviteCoAuthors' | 'share' + | 'cropImage' export const MODALS: Record = { auth: 'auth', @@ -44,6 +45,7 @@ export const MODALS: Record = { search: 'search', inviteCoAuthors: 'inviteCoAuthors', share: 'share', + cropImage: 'cropImage', } const [modal, setModal] = createSignal(null) diff --git a/src/styles/app.scss b/src/styles/app.scss index 3380f5b5..0fb518ce 100644 --- a/src/styles/app.scss +++ b/src/styles/app.scss @@ -1074,3 +1074,31 @@ iframe { .img-align-column { clear: both; } + +.cropper-modal { + background-color: #fff !important; +} + +.cropper-canvas { + filter: blur(2px); +} + +.cropper-view-box, +.cropper-crop-box, +.cropper-line, +.cropper-point { + box-shadow: none !important; + outline: none !important; + border: none !important; + background-color: transparent !important; +} + +.cropper-crop-box { + border: 2px solid #000 !important; + border-radius: 8px; +} + +.cropper-view-box, +.cropper-face { + border-radius: 50%; +}