From 47587812b809fee2a95c76266d9d0e42fc4ac1ca Mon Sep 17 00:00:00 2001 From: crupest Date: Tue, 15 Jun 2021 14:14:28 +0800 Subject: ... --- FrontEnd/src/views/settings/ChangeAvatarDialog.tsx | 305 +++++++++++++++++++++ .../src/views/settings/ChangeNicknameDialog.tsx | 32 +++ .../src/views/settings/ChangePasswordDialog.tsx | 68 +++++ FrontEnd/src/views/settings/index.tsx | 138 ++++++++++ FrontEnd/src/views/settings/settings.sass | 14 + 5 files changed, 557 insertions(+) create mode 100644 FrontEnd/src/views/settings/ChangeAvatarDialog.tsx create mode 100644 FrontEnd/src/views/settings/ChangeNicknameDialog.tsx create mode 100644 FrontEnd/src/views/settings/ChangePasswordDialog.tsx create mode 100644 FrontEnd/src/views/settings/index.tsx create mode 100644 FrontEnd/src/views/settings/settings.sass (limited to 'FrontEnd/src/views/settings') diff --git a/FrontEnd/src/views/settings/ChangeAvatarDialog.tsx b/FrontEnd/src/views/settings/ChangeAvatarDialog.tsx new file mode 100644 index 00000000..338d2112 --- /dev/null +++ b/FrontEnd/src/views/settings/ChangeAvatarDialog.tsx @@ -0,0 +1,305 @@ +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 = (props) => { + const { t } = useTranslation(); + + const user = useUserLoggedIn(); + + const [file, setFile] = React.useState(null); + const [fileUrl, setFileUrl] = React.useState(null); + const [clip, setClip] = React.useState(null); + const [cropImgElement, setCropImgElement] = + React.useState(null); + const [resultBlob, setResultBlob] = React.useState(null); + const [resultUrl, setResultUrl] = React.useState(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): 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 ( + + {t("settings.dialogChangeAvatar.previewImgAlt")} + + ); + }; + + return ( + + + {t("settings.dialogChangeAvatar.title")} + + {(() => { + if (state === "select") { + return ( + <> + + {t("settings.dialogChangeAvatar.prompt.select")} + + + + + + + + + ); + } else if (state === "crop") { + if (fileUrl == null) { + throw new UiLogicError(); + } + return ( + <> + + + + + {t("settings.dialogChangeAvatar.prompt.crop")} + + + + + + + + ); + } else if (state === "processcrop") { + return ( + <> + + + {t("settings.dialogChangeAvatar.prompt.processingCrop")} + + + + + + + + ); + } else if (state === "preview") { + return ( + <> + + {createPreviewRow()} + {t("settings.dialogChangeAvatar.prompt.preview")} + + + + + + + + ); + } else if (state === "uploading") { + return ( + <> + + {createPreviewRow()} + {t("settings.dialogChangeAvatar.prompt.uploading")} + + + + ); + } else if (state === "success") { + return ( + <> + + + {t("operationDialog.success")} + + + + + + + ); + } else { + return ( + <> + + {createPreviewRow()} + {trueMessage} + + + + + + + ); + } + })()} + + ); +}; + +export default ChangeAvatarDialog; diff --git a/FrontEnd/src/views/settings/ChangeNicknameDialog.tsx b/FrontEnd/src/views/settings/ChangeNicknameDialog.tsx new file mode 100644 index 00000000..e6420f36 --- /dev/null +++ b/FrontEnd/src/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 = (props) => { + const user = useUserLoggedIn(); + + return ( + { + return getHttpUserClient().patch(user.username, { + nickname: newNickname, + }); + }} + close={props.close} + /> + ); +}; + +export default ChangeNicknameDialog; diff --git a/FrontEnd/src/views/settings/ChangePasswordDialog.tsx b/FrontEnd/src/views/settings/ChangePasswordDialog.tsx new file mode 100644 index 00000000..21eeeb09 --- /dev/null +++ b/FrontEnd/src/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 = (props) => { + const history = useHistory(); + + const [redirect, setRedirect] = useState(false); + + return ( + { + const result: Record = {}; + 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/views/settings/index.tsx b/FrontEnd/src/views/settings/index.tsx new file mode 100644 index 00000000..04a2777a --- /dev/null +++ b/FrontEnd/src/views/settings/index.tsx @@ -0,0 +1,138 @@ +import React, { useState } from "react"; +import { useHistory } from "react-router"; +import { useTranslation } from "react-i18next"; +import { Container, Form, Row, Col, Button, Modal } from "react-bootstrap"; + +import { useUser, userService } from "@/services/user"; + +import ChangePasswordDialog from "./ChangePasswordDialog"; +import ChangeAvatarDialog from "./ChangeAvatarDialog"; +import ChangeNicknameDialog from "./ChangeNicknameDialog"; + +const ConfirmLogoutDialog: React.FC<{ + onClose: () => void; + onConfirm: () => void; +}> = ({ onClose, onConfirm }) => { + const { t } = useTranslation(); + + return ( + + + + {t("settings.dialogConfirmLogout.title")} + + + {t("settings.dialogConfirmLogout.prompt")} + + + + + + ); +}; + +const SettingsPage: React.FC = (_) => { + const { i18n, t } = useTranslation(); + const user = useUser(); + const history = useHistory(); + + const [dialog, setDialog] = useState< + null | "changepassword" | "changeavatar" | "changenickname" | "logout" + >(null); + + const language = i18n.language.slice(0, 2); + + return ( + <> + + {user ? ( +
+

+ {t("settings.subheaders.account")} +

+
setDialog("changeavatar")} + > + {t("settings.changeAvatar")} +
+
setDialog("changenickname")} + > + {t("settings.changeNickname")} +
+
setDialog("changepassword")} + > + {t("settings.changePassword")} +
+
{ + setDialog("logout"); + }} + > + {t("settings.logout")} +
+
+ ) : null} +
+

+ {t("settings.subheaders.customization")} +

+ + +
{t("settings.languagePrimary")}
+ + {t("settings.languageSecondary")} + + + + { + void i18n.changeLanguage(e.target.value); + }} + > + + + + +
+
+
+ {(() => { + switch (dialog) { + case "changepassword": + return setDialog(null)} />; + case "logout": + return ( + setDialog(null)} + onConfirm={() => { + void userService.logout().then(() => { + history.push("/"); + }); + }} + /> + ); + case "changeavatar": + return setDialog(null)} />; + case "changenickname": + return setDialog(null)} />; + default: + return null; + } + })()} + + ); +}; + +export default SettingsPage; diff --git a/FrontEnd/src/views/settings/settings.sass b/FrontEnd/src/views/settings/settings.sass new file mode 100644 index 00000000..8c6d24b8 --- /dev/null +++ b/FrontEnd/src/views/settings/settings.sass @@ -0,0 +1,14 @@ +.settings-item + padding: 0.5em 1em + transition: background 0.3s + border-bottom: 1px solid $gray-200 + + &.first + border-top: 1px solid $gray-200 + + &.clickable + cursor: pointer + + &:hover + background: $gray-300 + -- cgit v1.2.3