diff options
Diffstat (limited to 'Timeline/ClientApp/src/app/user/ChangeAvatarDialog.tsx')
-rw-r--r-- | Timeline/ClientApp/src/app/user/ChangeAvatarDialog.tsx | 306 |
1 files changed, 306 insertions, 0 deletions
diff --git a/Timeline/ClientApp/src/app/user/ChangeAvatarDialog.tsx b/Timeline/ClientApp/src/app/user/ChangeAvatarDialog.tsx new file mode 100644 index 00000000..a0e56621 --- /dev/null +++ b/Timeline/ClientApp/src/app/user/ChangeAvatarDialog.tsx @@ -0,0 +1,306 @@ +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Modal, + ModalHeader, + Row, + Button, + ModalBody, + ModalFooter, +} from 'reactstrap'; +import { AxiosError } from 'axios'; + +import ImageCropper, { Clip, applyClipToImage } from '../common/ImageCropper'; +import { UiLogicError } from '../common'; + +export interface ChangeAvatarDialogProps { + open: boolean; + close: () => void; + process: (blob: Blob) => Promise<void>; +} + +const ChangeAvatarDialog: React.FC<ChangeAvatarDialogProps> = (props) => { + const { t } = useTranslation(); + + const [file, setFile] = React.useState<File | null>(null); + const [fileUrl, setFileUrl] = React.useState<string | null>(null); + const [clip, setClip] = React.useState<Clip | null>(null); + const [ + cropImgElement, + setCropImgElement, + ] = React.useState<HTMLImageElement | null>(null); + const [resultBlob, setResultBlob] = React.useState<Blob | null>(null); + const [resultUrl, setResultUrl] = React.useState<string | null>(null); + + const [state, setState] = React.useState< + | 'select' + | 'crop' + | 'processcrop' + | 'preview' + | 'uploading' + | 'success' + | 'error' + >('select'); + + const [message, setMessage] = useState< + string | { type: 'custom'; text: string } | null + >('userPage.dialogChangeAvatar.prompt.select'); + + const trueMessage = + message == null + ? null + : typeof message === 'string' + ? t(message) + : message.text; + + const closeDialog = props.close; + + const toggle = React.useCallback((): void => { + if (!(state === 'uploading')) { + closeDialog(); + } + }, [state, closeDialog]); + + useEffect(() => { + if (file != null) { + const url = URL.createObjectURL(file); + setClip(null); + setFileUrl(url); + setState('crop'); + return () => { + URL.revokeObjectURL(url); + }; + } else { + setFileUrl(null); + setState('select'); + } + }, [file]); + + React.useEffect(() => { + if (resultBlob != null) { + const url = URL.createObjectURL(resultBlob); + setResultUrl(url); + setState('preview'); + return () => { + URL.revokeObjectURL(url); + }; + } else { + setResultUrl(null); + } + }, [resultBlob]); + + const onSelectFile = React.useCallback( + (e: React.ChangeEvent<HTMLInputElement>): void => { + const files = e.target.files; + if (files == null || files.length === 0) { + setFile(null); + } else { + setFile(files[0]); + } + }, + [] + ); + + const onCropNext = React.useCallback(() => { + if ( + cropImgElement == null || + clip == null || + clip.width === 0 || + file == null + ) { + throw new UiLogicError(); + } + + setState('processcrop'); + void applyClipToImage(cropImgElement, clip, file.type).then((b) => { + setResultBlob(b); + }); + }, [cropImgElement, clip, file]); + + const onCropPrevious = React.useCallback(() => { + setFile(null); + setState('select'); + }, []); + + const onPreviewPrevious = React.useCallback(() => { + setResultBlob(null); + setState('crop'); + }, []); + + const process = props.process; + + const upload = React.useCallback(() => { + if (resultBlob == null) { + throw new UiLogicError(); + } + + setState('uploading'); + process(resultBlob).then( + () => { + setState('success'); + }, + (e: unknown) => { + setState('error'); + setMessage({ type: 'custom', text: (e as AxiosError).message }); + } + ); + }, [resultBlob, process]); + + const createPreviewRow = (): React.ReactElement => { + if (resultUrl == null) { + throw new UiLogicError(); + } + return ( + <Row className="justify-content-center"> + <img + className="change-avatar-img" + src={resultUrl} + alt={t('userPage.dialogChangeAvatar.previewImgAlt')} + /> + </Row> + ); + }; + + return ( + <Modal isOpen={props.open} toggle={toggle}> + <ModalHeader> {t('userPage.dialogChangeAvatar.title')}</ModalHeader> + {(() => { + if (state === 'select') { + return ( + <> + <ModalBody className="container"> + <Row>{t('userPage.dialogChangeAvatar.prompt.select')}</Row> + <Row> + <input type="file" accept="image/*" onChange={onSelectFile} /> + </Row> + </ModalBody> + <ModalFooter> + <Button color="secondary" onClick={toggle}> + {t('operationDialog.cancel')} + </Button> + </ModalFooter> + </> + ); + } else if (state === 'crop') { + if (fileUrl == null) { + throw new UiLogicError(); + } + return ( + <> + <ModalBody className="container"> + <Row className="justify-content-center"> + <ImageCropper + clip={clip} + onChange={setClip} + imageUrl={fileUrl} + imageElementCallback={setCropImgElement} + /> + </Row> + <Row>{t('userPage.dialogChangeAvatar.prompt.crop')}</Row> + </ModalBody> + <ModalFooter> + <Button color="secondary" onClick={toggle}> + {t('operationDialog.cancel')} + </Button> + <Button color="secondary" onClick={onCropPrevious}> + {t('operationDialog.previousStep')} + </Button> + <Button + color="primary" + onClick={onCropNext} + disabled={ + cropImgElement == null || clip == null || clip.width === 0 + } + > + {t('operationDialog.nextStep')} + </Button> + </ModalFooter> + </> + ); + } else if (state === 'processcrop') { + return ( + <> + <ModalBody className="container"> + <Row> + {t('userPage.dialogChangeAvatar.prompt.processingCrop')} + </Row> + </ModalBody> + <ModalFooter> + <Button color="secondary" onClick={toggle}> + {t('operationDialog.cancel')} + </Button> + <Button color="secondary" onClick={onPreviewPrevious}> + {t('operationDialog.previousStep')} + </Button> + </ModalFooter> + </> + ); + } else if (state === 'preview') { + return ( + <> + <ModalBody className="container"> + {createPreviewRow()} + <Row>{t('userPage.dialogChangeAvatar.prompt.preview')}</Row> + </ModalBody> + <ModalFooter> + <Button color="secondary" onClick={toggle}> + {t('operationDialog.cancel')} + </Button> + <Button color="secondary" onClick={onPreviewPrevious}> + {t('operationDialog.previousStep')} + </Button> + <Button color="primary" onClick={upload}> + {t('userPage.dialogChangeAvatar.upload')} + </Button> + </ModalFooter> + </> + ); + } else if (state === 'uploading') { + return ( + <> + <ModalBody className="container"> + {createPreviewRow()} + <Row>{t('userPage.dialogChangeAvatar.prompt.uploading')}</Row> + </ModalBody> + <ModalFooter></ModalFooter> + </> + ); + } else if (state === 'success') { + return ( + <> + <ModalBody className="container"> + <Row className="p-4 text-success"> + {t('operationDialog.success')} + </Row> + </ModalBody> + <ModalFooter> + <Button color="success" onClick={toggle}> + {t('operationDialog.ok')} + </Button> + </ModalFooter> + </> + ); + } else { + return ( + <> + <ModalBody className="container"> + {createPreviewRow()} + <Row className="text-danger">{trueMessage}</Row> + </ModalBody> + <ModalFooter> + <Button color="secondary" onClick={toggle}> + {t('operationDialog.cancel')} + </Button> + <Button color="primary" onClick={upload}> + {t('operationDialog.retry')} + </Button> + </ModalFooter> + </> + ); + } + })()} + </Modal> + ); +}; + +export default ChangeAvatarDialog; |