implement image crop

This commit is contained in:
dog 2024-01-25 15:41:25 +03:00
parent 3429b36502
commit 591fd2ecbf
7 changed files with 157 additions and 113 deletions

View File

@ -105,6 +105,7 @@
"Create gallery": "Create gallery", "Create gallery": "Create gallery",
"Create post": "Create post", "Create post": "Create post",
"Create video": "Create video", "Create video": "Create video",
"Crop image": "Crop image",
"Culture": "Culture", "Culture": "Culture",
"Date of Birth": "Date of Birth", "Date of Birth": "Date of Birth",
"Decline": "Decline", "Decline": "Decline",

View File

@ -109,6 +109,7 @@
"Create gallery": "Создать галерею", "Create gallery": "Создать галерею",
"Create post": "Создать публикацию", "Create post": "Создать публикацию",
"Create video": "Создать видео", "Create video": "Создать видео",
"Crop image": "Скадрируйте изображение",
"Culture": "Культура", "Culture": "Культура",
"Date of Birth": "Дата рождения", "Date of Birth": "Дата рождения",
"Decline": "Отмена", "Decline": "Отмена",

View File

@ -14,13 +14,18 @@ import { getImageUrl } from '../../utils/getImageUrl'
import { handleImageUpload } from '../../utils/handleImageUpload' import { handleImageUpload } from '../../utils/handleImageUpload'
import { profileSocialLinks } from '../../utils/profileSocialLinks' import { profileSocialLinks } from '../../utils/profileSocialLinks'
import { validateUrl } from '../../utils/validateUrl' import { validateUrl } from '../../utils/validateUrl'
import { Modal } from '../Nav/Modal'
import { Button } from '../_shared/Button' import { Button } from '../_shared/Button'
import { Icon } from '../_shared/Icon' import { Icon } from '../_shared/Icon'
import { Loading } from '../_shared/Loading' import { Loading } from '../_shared/Loading'
import { Popover } from '../_shared/Popover' import { Popover } from '../_shared/Popover'
import { SocialNetworkInput } from '../_shared/SocialNetworkInput' import { SocialNetworkInput } from '../_shared/SocialNetworkInput'
import { ImageCropper } from '../_shared/ImageCropper'
import { ProfileSettingsNavigation } from '../Nav/ProfileSettingsNavigation' import { ProfileSettingsNavigation } from '../Nav/ProfileSettingsNavigation'
import { showModal, hideModal } from '../../stores/ui'
import styles from '../../pages/profile/Settings.module.scss' import styles from '../../pages/profile/Settings.module.scss'
const SimplifiedEditor = lazy(() => import('../../components/Editor/SimplifiedEditor')) const SimplifiedEditor = lazy(() => import('../../components/Editor/SimplifiedEditor'))
@ -28,12 +33,14 @@ const GrowingTextarea = lazy(() => import('../../components/_shared/GrowingTexta
export const ProfileSettings = () => { export const ProfileSettings = () => {
const { t } = useLocalize() const { t } = useLocalize()
const [prevForm, setPrevForm] = createStore({}) const [prevForm, setPrevForm] = createStore({})
const [isFormInitialized, setIsFormInitialized] = createSignal(false) const [isFormInitialized, setIsFormInitialized] = createSignal(false)
const [social, setSocial] = createSignal([]) const [social, setSocial] = createSignal([])
const [addLinkForm, setAddLinkForm] = createSignal<boolean>(false) const [addLinkForm, setAddLinkForm] = createSignal<boolean>(false)
const [incorrectUrl, setIncorrectUrl] = createSignal<boolean>(false) const [incorrectUrl, setIncorrectUrl] = createSignal<boolean>(false)
const [isUserpicUpdating, setIsUserpicUpdating] = createSignal(false) const [isUserpicUpdating, setIsUserpicUpdating] = createSignal(false)
const [userpicFile, setUserpicFile] = createSignal<any | null>(null)
const [uploadError, setUploadError] = createSignal(false) const [uploadError, setUploadError] = createSignal(false)
const [isFloatingPanelVisible, setIsFloatingPanelVisible] = createSignal(false) const [isFloatingPanelVisible, setIsFloatingPanelVisible] = createSignal(false)
const [hostname, setHostname] = createSignal<string | null>(null) const [hostname, setHostname] = createSignal<string | null>(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(([uploadFile]) => {
selectFiles(async ([uploadFile]) => { setUserpicFile(uploadFile)
try {
setUploadError(false) showModal('cropImage')
setIsUserpicUpdating(true)
const result = await handleImageUpload(uploadFile)
updateFormField('userpic', result.url)
setIsUserpicUpdating(false)
} catch (error) {
setUploadError(true)
console.error('[upload avatar] error', error)
}
}) })
} }
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(() => { onMount(() => {
setHostname(window?.location.host) setHostname(window?.location.host)
@ -178,7 +194,7 @@ export const ProfileSettings = () => {
<div class="pretty-form__item"> <div class="pretty-form__item">
<div <div
class={clsx(styles.userpic, { [styles.hasControls]: form.userpic })} class={clsx(styles.userpic, { [styles.hasControls]: form.userpic })}
onClick={!form.userpic && handleUploadAvatar} onClick={handleCropAvatar}
> >
<Switch> <Switch>
<Match when={isUserpicUpdating()}> <Match when={isUserpicUpdating()}>
@ -206,17 +222,19 @@ export const ProfileSettings = () => {
</button> </button>
)} )}
</Popover> </Popover>
<Popover content={t('Upload userpic')}>
{/* @@TODO inspect popover below. onClick causes page refreshing */}
{/* <Popover content={t('Upload userpic')}>
{(triggerRef: (el) => void) => ( {(triggerRef: (el) => void) => (
<button <button
ref={triggerRef} ref={triggerRef}
class={styles.control} class={styles.control}
onClick={handleUploadAvatar} onClick={() => handleCropAvatar()}
> >
<Icon name="user-image-black" /> <Icon name="user-image-black" />
</button> </button>
)} )}
</Popover> </Popover> */}
</div> </div>
</Match> </Match>
<Match when={!form.userpic}> <Match when={!form.userpic}>
@ -365,6 +383,21 @@ export const ProfileSettings = () => {
</div> </div>
</div> </div>
</Show> </Show>
<Modal variant="medium" name="cropImage" onClose={() => setUserpicFile(null)}>
<h2>{t('Crop image')}</h2>
<Show when={userpicFile()}>
<ImageCropper
uploadFile={userpicFile()}
onSave={(data) => {
handleUploadAvatar(data)
hideModal()
}}
onDecline={() => hideModal()}
/>
</Show>
</Modal>
</> </>
</Show> </Show>
) )

View File

@ -0,0 +1,6 @@
.cropperControls {
display: flex;
justify-content: space-between;
margin-top: 2rem;
}

View File

@ -1,105 +1,78 @@
import { createSignal, Show } from 'solid-js' import 'cropperjs/dist/cropper.css'
import { createStore } from 'solid-js/store'
import { createSignal, onMount, Show } from 'solid-js'
import Cropper from 'cropperjs' 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' import styles from './ImageCropper.module.scss'
export const ImageCropper = (props) => { interface CropperProps {
let cropImage uploadFile: UploadFile
const [state, setState] = createStore({ onSave: (any) => void
error: null, onDecline?: () => void
loading: false, }
file: {},
croppedImage: null, export const ImageCropper = (props: CropperProps) => {
}), const { t } = useLocalize()
[dropZoneActive, setDropZoneActive] = createSignal(false),
[uploading, setUploading] = createSignal(false), const imageTagRef: { current: HTMLImageElement } = {
[preview, setPreview] = createSignal(null), current: null,
[cropper, setCropper] = createSignal(null), }
noPropagate = (e) => {
e.preventDefault() const [cropper, setCropper] = createSignal(null)
},
uploadFile = async (file) => { onMount(() => {
if (!file) return if (imageTagRef.current) {
setUploading(true) setCropper(
setState('loading', true) new Cropper(imageTagRef.current, {
setState('file', file) viewMode: 1,
try { aspectRatio: 1,
const reader = new FileReader() guides: false,
reader.onload = (e) => { background: false,
setPreview(e.target.result) rotatable: false,
setCropper( modal: true,
new Cropper(cropImage, { }),
aspectRatio: 1 / 1, )
viewMode: 1,
rotatable: false,
}),
)
}
reader.readAsDataURL(file)
} catch (e) {
console.error('upload failed', e)
const message = e instanceof Error ? e.message : String(e)
setState('error', message)
}
setState('loading', false)
setUploading(false)
},
handleFileDrop = async (e) => {
e.preventDefault()
setDropZoneActive(false)
uploadFile(e.dataTransfer.files[0])
},
handleFileInput = async (e) => {
e.preventDefault()
uploadFile(e.currentTarget.files[0])
} }
})
return ( return (
<> <div>
<Show when={preview() !== null}> <div>
<div> <img
<div> ref={(el) => (imageTagRef.current = el)}
<img ref={cropImage} src={preview()} alt="cropper" class="block max-w-full h-96 w-96" /> src={props.uploadFile.source}
</div> alt="image crop panel"
<button />
type="button" </div>
class="inline-flex items-center gap-x-1.5 rounded-md bg-indigo-600 py-2 px-3 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600 mt-2"
onClick={() => { <div class={styles.cropperControls}>
setState('croppedImage', cropper().getCroppedCanvas().toDataURL(state.file.type)) <Show when={props.onDecline}>
props.saveImage(state) <Button variant="secondary" onClick={props.onDecline} value={t('Decline')} />
}} </Show>
>
Save <Button
</button> variant="primary"
</div> onClick={() => {
</Show> cropper()
<Show when={preview() === null}> .getCroppedCanvas()
<form class="min-h-96 min-w-96"> .toBlob((blob) => {
<div const formData = new FormData()
id="dropzone" formData.append('media', blob, props.uploadFile.file.name)
class={`${dropZoneActive() ? 'bg-green-100' : ''} ${
uploading() && 'opacity-50' props.onSave({
} place-content-center place-items-center h-96 w-96 border-2 border-gray-300 border-dashed rounded-md sm:flex p-2 m-2`} ...props.uploadFile,
onDragEnter={() => (uploading() ? undefined : setDropZoneActive(true))} file: formData.get('media'),
onDragLeave={() => setDropZoneActive(false)} })
onDragOver={noPropagate} })
onDrop={(event) => (uploading() ? noPropagate(event) : handleFileDrop(event))} }}
> value={t('Save')}
<div class="">upload</div> />
<input </div>
id="image-upload" </div>
name="file"
type="file"
disabled={uploading()}
multiple={false}
onInput={handleFileInput}
class="sr-only"
/>
</div>
<div class="h-8" />
</form>
</Show>
</>
) )
} }

View File

@ -25,6 +25,7 @@ export type ModalType =
| 'following' | 'following'
| 'inviteCoAuthors' | 'inviteCoAuthors'
| 'share' | 'share'
| 'cropImage'
export const MODALS: Record<ModalType, ModalType> = { export const MODALS: Record<ModalType, ModalType> = {
auth: 'auth', auth: 'auth',
@ -42,6 +43,7 @@ export const MODALS: Record<ModalType, ModalType> = {
following: 'following', following: 'following',
inviteCoAuthors: 'inviteCoAuthors', inviteCoAuthors: 'inviteCoAuthors',
share: 'share', share: 'share',
cropImage: 'cropImage',
} }
const [modal, setModal] = createSignal<ModalType>(null) const [modal, setModal] = createSignal<ModalType>(null)

View File

@ -1070,3 +1070,31 @@ iframe {
.img-align-column { .img-align-column {
clear: both; 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%;
}