aboutsummaryrefslogtreecommitdiff
path: root/FrontEnd/src/app/views/settings
diff options
context:
space:
mode:
authorcrupest <crupest@outlook.com>2021-02-15 01:23:24 +0800
committercrupest <crupest@outlook.com>2021-02-15 01:23:24 +0800
commit19265fb44fe0970e0a6c9afe8f2b48571aee9e75 (patch)
tree134a56bfcb2f924f13848df0df6b9fe0fd140da4 /FrontEnd/src/app/views/settings
parent58e23e759d730dd9d9733a64e5f16cc5aafeba35 (diff)
downloadtimeline-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.tsx307
-rw-r--r--FrontEnd/src/app/views/settings/ChangeNicknameDialog.tsx32
-rw-r--r--FrontEnd/src/app/views/settings/ChangePasswordDialog.tsx68
-rw-r--r--FrontEnd/src/app/views/settings/index.tsx94
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;
}