diff options
author | crupest <crupest@outlook.com> | 2021-02-15 01:23:24 +0800 |
---|---|---|
committer | crupest <crupest@outlook.com> | 2021-02-15 01:23:24 +0800 |
commit | 19265fb44fe0970e0a6c9afe8f2b48571aee9e75 (patch) | |
tree | 134a56bfcb2f924f13848df0df6b9fe0fd140da4 /FrontEnd/src/app/views/settings | |
parent | 58e23e759d730dd9d9733a64e5f16cc5aafeba35 (diff) | |
download | timeline-19265fb44fe0970e0a6c9afe8f2b48571aee9e75.tar.gz timeline-19265fb44fe0970e0a6c9afe8f2b48571aee9e75.tar.bz2 timeline-19265fb44fe0970e0a6c9afe8f2b48571aee9e75.zip |
feat: Move change avatar and nickname to settings.
Diffstat (limited to 'FrontEnd/src/app/views/settings')
-rw-r--r-- | FrontEnd/src/app/views/settings/ChangeAvatarDialog.tsx | 307 | ||||
-rw-r--r-- | FrontEnd/src/app/views/settings/ChangeNicknameDialog.tsx | 32 | ||||
-rw-r--r-- | FrontEnd/src/app/views/settings/ChangePasswordDialog.tsx | 68 | ||||
-rw-r--r-- | FrontEnd/src/app/views/settings/index.tsx | 94 |
4 files changed, 426 insertions, 75 deletions
diff --git a/FrontEnd/src/app/views/settings/ChangeAvatarDialog.tsx b/FrontEnd/src/app/views/settings/ChangeAvatarDialog.tsx new file mode 100644 index 00000000..53ffbc8d --- /dev/null +++ b/FrontEnd/src/app/views/settings/ChangeAvatarDialog.tsx @@ -0,0 +1,307 @@ +import React, { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { AxiosError } from "axios"; +import { Modal, Row, Button } from "react-bootstrap"; + +import { UiLogicError } from "@/common"; + +import { useUserLoggedIn } from "@/services/user"; + +import { getHttpUserClient } from "@/http/user"; + +import ImageCropper, { Clip, applyClipToImage } from "../common/ImageCropper"; + +export interface ChangeAvatarDialogProps { + open: boolean; + close: () => void; +} + +const ChangeAvatarDialog: React.FC<ChangeAvatarDialogProps> = (props) => { + const { t } = useTranslation(); + + const user = useUserLoggedIn(); + + 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 + >("settings.dialogChangeAvatar.prompt.select"); + + const trueMessage = + message == null + ? null + : typeof message === "string" + ? t(message) + : message.text; + + const closeDialog = props.close; + + const close = 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 upload = React.useCallback(() => { + if (resultBlob == null) { + throw new UiLogicError(); + } + + setState("uploading"); + getHttpUserClient() + .putAvatar(user.username, resultBlob) + .then( + () => { + setState("success"); + }, + (e: unknown) => { + setState("error"); + setMessage({ type: "custom", text: (e as AxiosError).message }); + } + ); + }, [user.username, resultBlob]); + + 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("settings.dialogChangeAvatar.previewImgAlt")} + /> + </Row> + ); + }; + + return ( + <Modal show={props.open} onHide={close}> + <Modal.Header> + <Modal.Title> {t("settings.dialogChangeAvatar.title")}</Modal.Title> + </Modal.Header> + {(() => { + if (state === "select") { + return ( + <> + <Modal.Body className="container"> + <Row>{t("settings.dialogChangeAvatar.prompt.select")}</Row> + <Row> + <input type="file" accept="image/*" onChange={onSelectFile} /> + </Row> + </Modal.Body> + <Modal.Footer> + <Button variant="secondary" onClick={close}> + {t("operationDialog.cancel")} + </Button> + </Modal.Footer> + </> + ); + } else if (state === "crop") { + if (fileUrl == null) { + throw new UiLogicError(); + } + return ( + <> + <Modal.Body className="container"> + <Row className="justify-content-center"> + <ImageCropper + clip={clip} + onChange={setClip} + imageUrl={fileUrl} + imageElementCallback={setCropImgElement} + /> + </Row> + <Row>{t("settings.dialogChangeAvatar.prompt.crop")}</Row> + </Modal.Body> + <Modal.Footer> + <Button variant="secondary" onClick={close}> + {t("operationDialog.cancel")} + </Button> + <Button variant="secondary" onClick={onCropPrevious}> + {t("operationDialog.previousStep")} + </Button> + <Button + color="primary" + onClick={onCropNext} + disabled={ + cropImgElement == null || clip == null || clip.width === 0 + } + > + {t("operationDialog.nextStep")} + </Button> + </Modal.Footer> + </> + ); + } else if (state === "processcrop") { + return ( + <> + <Modal.Body className="container"> + <Row> + {t("settings.dialogChangeAvatar.prompt.processingCrop")} + </Row> + </Modal.Body> + <Modal.Footer> + <Button variant="secondary" onClick={close}> + {t("operationDialog.cancel")} + </Button> + <Button variant="secondary" onClick={onPreviewPrevious}> + {t("operationDialog.previousStep")} + </Button> + </Modal.Footer> + </> + ); + } else if (state === "preview") { + return ( + <> + <Modal.Body className="container"> + {createPreviewRow()} + <Row>{t("settings.dialogChangeAvatar.prompt.preview")}</Row> + </Modal.Body> + <Modal.Footer> + <Button variant="secondary" onClick={close}> + {t("operationDialog.cancel")} + </Button> + <Button variant="secondary" onClick={onPreviewPrevious}> + {t("operationDialog.previousStep")} + </Button> + <Button variant="primary" onClick={upload}> + {t("settings.dialogChangeAvatar.upload")} + </Button> + </Modal.Footer> + </> + ); + } else if (state === "uploading") { + return ( + <> + <Modal.Body className="container"> + {createPreviewRow()} + <Row>{t("settings.dialogChangeAvatar.prompt.uploading")}</Row> + </Modal.Body> + <Modal.Footer></Modal.Footer> + </> + ); + } else if (state === "success") { + return ( + <> + <Modal.Body className="container"> + <Row className="p-4 text-success"> + {t("operationDialog.success")} + </Row> + </Modal.Body> + <Modal.Footer> + <Button variant="success" onClick={close}> + {t("operationDialog.ok")} + </Button> + </Modal.Footer> + </> + ); + } else { + return ( + <> + <Modal.Body className="container"> + {createPreviewRow()} + <Row className="text-danger">{trueMessage}</Row> + </Modal.Body> + <Modal.Footer> + <Button variant="secondary" onClick={close}> + {t("operationDialog.cancel")} + </Button> + <Button variant="primary" onClick={upload}> + {t("operationDialog.retry")} + </Button> + </Modal.Footer> + </> + ); + } + })()} + </Modal> + ); +}; + +export default ChangeAvatarDialog; diff --git a/FrontEnd/src/app/views/settings/ChangeNicknameDialog.tsx b/FrontEnd/src/app/views/settings/ChangeNicknameDialog.tsx new file mode 100644 index 00000000..4b44cdd6 --- /dev/null +++ b/FrontEnd/src/app/views/settings/ChangeNicknameDialog.tsx @@ -0,0 +1,32 @@ +import { getHttpUserClient } from "@/http/user"; +import { useUserLoggedIn } from "@/services/user"; +import React from "react"; + +import OperationDialog from "../common/OperationDialog"; + +export interface ChangeNicknameDialogProps { + open: boolean; + close: () => void; +} + +const ChangeNicknameDialog: React.FC<ChangeNicknameDialogProps> = (props) => { + const user = useUserLoggedIn(); + + return ( + <OperationDialog + open={props.open} + title="settings.dialogChangeNickname.title" + inputScheme={[ + { type: "text", label: "settings.dialogChangeNickname.inputLabel" }, + ]} + onProcess={([newNickname]) => { + return getHttpUserClient().patch(user.username, { + nickname: newNickname, + }); + }} + close={props.close} + /> + ); +}; + +export default ChangeNicknameDialog; diff --git a/FrontEnd/src/app/views/settings/ChangePasswordDialog.tsx b/FrontEnd/src/app/views/settings/ChangePasswordDialog.tsx new file mode 100644 index 00000000..21eeeb09 --- /dev/null +++ b/FrontEnd/src/app/views/settings/ChangePasswordDialog.tsx @@ -0,0 +1,68 @@ +import React, { useState } from "react"; +import { useHistory } from "react-router"; + +import { userService } from "@/services/user"; + +import OperationDialog from "../common/OperationDialog"; + +export interface ChangePasswordDialogProps { + open: boolean; + close: () => void; +} + +const ChangePasswordDialog: React.FC<ChangePasswordDialogProps> = (props) => { + const history = useHistory(); + + const [redirect, setRedirect] = useState<boolean>(false); + + return ( + <OperationDialog + open={props.open} + title="settings.dialogChangePassword.title" + themeColor="danger" + inputPrompt="settings.dialogChangePassword.prompt" + inputScheme={[ + { + type: "text", + label: "settings.dialogChangePassword.inputOldPassword", + password: true, + }, + { + type: "text", + label: "settings.dialogChangePassword.inputNewPassword", + password: true, + }, + { + type: "text", + label: "settings.dialogChangePassword.inputRetypeNewPassword", + password: true, + }, + ]} + inputValidator={([oldPassword, newPassword, retypedNewPassword]) => { + const result: Record<number, string> = {}; + if (oldPassword === "") { + result[0] = "settings.dialogChangePassword.errorEmptyOldPassword"; + } + if (newPassword === "") { + result[1] = "settings.dialogChangePassword.errorEmptyNewPassword"; + } + if (retypedNewPassword !== newPassword) { + result[2] = "settings.dialogChangePassword.errorRetypeNotMatch"; + } + return result; + }} + onProcess={async ([oldPassword, newPassword]) => { + await userService.changePassword(oldPassword, newPassword); + setRedirect(true); + }} + close={() => { + props.close(); + if (redirect) { + history.push("/login"); + } + }} + /> + ); +}; + +export default ChangePasswordDialog; diff --git a/FrontEnd/src/app/views/settings/index.tsx b/FrontEnd/src/app/views/settings/index.tsx index ccba59b7..6710ea25 100644 --- a/FrontEnd/src/app/views/settings/index.tsx +++ b/FrontEnd/src/app/views/settings/index.tsx @@ -4,67 +4,10 @@ import { useTranslation } from "react-i18next"; import { Container, Form, Row, Col, Button, Modal } from "react-bootstrap"; import { useUser, userService } from "@/services/user"; -import OperationDialog from "../common/OperationDialog"; -interface ChangePasswordDialogProps { - open: boolean; - close: () => void; -} - -const ChangePasswordDialog: React.FC<ChangePasswordDialogProps> = (props) => { - const history = useHistory(); - - const [redirect, setRedirect] = useState<boolean>(false); - - return ( - <OperationDialog - open={props.open} - title="settings.dialogChangePassword.title" - themeColor="danger" - inputPrompt="settings.dialogChangePassword.prompt" - inputScheme={[ - { - type: "text", - label: "settings.dialogChangePassword.inputOldPassword", - password: true, - }, - { - type: "text", - label: "settings.dialogChangePassword.inputNewPassword", - password: true, - }, - { - type: "text", - label: "settings.dialogChangePassword.inputRetypeNewPassword", - password: true, - }, - ]} - inputValidator={([oldPassword, newPassword, retypedNewPassword]) => { - const result: Record<number, string> = {}; - if (oldPassword === "") { - result[0] = "settings.dialogChangePassword.errorEmptyOldPassword"; - } - if (newPassword === "") { - result[1] = "settings.dialogChangePassword.errorEmptyNewPassword"; - } - if (retypedNewPassword !== newPassword) { - result[2] = "settings.dialogChangePassword.errorRetypeNotMatch"; - } - return result; - }} - onProcess={async ([oldPassword, newPassword]) => { - await userService.changePassword(oldPassword, newPassword); - setRedirect(true); - }} - close={() => { - props.close(); - if (redirect) { - history.push("/login"); - } - }} - /> - ); -}; +import ChangePasswordDialog from "./ChangePasswordDialog"; +import ChangeAvatarDialog from "./ChangeAvatarDialog"; +import ChangeNicknameDialog from "./ChangeNicknameDialog"; const ConfirmLogoutDialog: React.FC<{ onClose: () => void; @@ -97,9 +40,9 @@ const SettingsPage: React.FC = (_) => { const user = useUser(); const history = useHistory(); - const [dialog, setDialog] = useState<null | "changepassword" | "logout">( - null - ); + const [dialog, setDialog] = useState< + null | "changepassword" | "changeavatar" | "changenickname" | "logout" + >(null); const language = i18n.language.slice(0, 2); @@ -113,11 +56,15 @@ const SettingsPage: React.FC = (_) => { </h3> <div className="settings-item clickable first" - onClick={() => { - history.push(`/users/${user.username}`); - }} + onClick={() => setDialog("changeavatar")} > - {t("settings.gotoSelf")} + {t("settings.changeAvatar")} + </div> + <div + className="settings-item clickable first" + onClick={() => setDialog("changenickname")} + > + {t("settings.changeNickname")} </div> <div className="settings-item clickable text-danger" @@ -164,14 +111,7 @@ const SettingsPage: React.FC = (_) => { {(() => { switch (dialog) { case "changepassword": - return ( - <ChangePasswordDialog - open - close={() => { - setDialog(null); - }} - /> - ); + return <ChangePasswordDialog open close={() => setDialog(null)} />; case "logout": return ( <ConfirmLogoutDialog @@ -183,6 +123,10 @@ const SettingsPage: React.FC = (_) => { }} /> ); + case "changeavatar": + return <ChangeAvatarDialog open close={() => setDialog(null)} />; + case "changenickname": + return <ChangeNicknameDialog open close={() => setDialog(null)} />; default: return null; } |