aboutsummaryrefslogtreecommitdiff
path: root/FrontEnd/src/pages/setting
diff options
context:
space:
mode:
authorcrupest <crupest@outlook.com>2023-09-20 20:26:42 +0800
committerGitHub <noreply@github.com>2023-09-20 20:26:42 +0800
commitf836d77e73f3ea0af45c5f71dae7268143d6d86f (patch)
tree573cfafd972106d69bef0d41ff5f270ec3c43ec2 /FrontEnd/src/pages/setting
parent4a069bf1268f393d5467166356f691eb89963152 (diff)
parent901fe3d7c032d284da5c9bce24c4aaee9054c7ac (diff)
downloadtimeline-f836d77e73f3ea0af45c5f71dae7268143d6d86f.tar.gz
timeline-f836d77e73f3ea0af45c5f71dae7268143d6d86f.tar.bz2
timeline-f836d77e73f3ea0af45c5f71dae7268143d6d86f.zip
Merge pull request #1395 from crupest/dev
Refector 2023 v0.1
Diffstat (limited to 'FrontEnd/src/pages/setting')
-rw-r--r--FrontEnd/src/pages/setting/ChangeAvatarDialog.css22
-rw-r--r--FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx276
-rw-r--r--FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx26
-rw-r--r--FrontEnd/src/pages/setting/ChangePasswordDialog.tsx70
-rw-r--r--FrontEnd/src/pages/setting/index.css76
-rw-r--r--FrontEnd/src/pages/setting/index.tsx297
6 files changed, 767 insertions, 0 deletions
diff --git a/FrontEnd/src/pages/setting/ChangeAvatarDialog.css b/FrontEnd/src/pages/setting/ChangeAvatarDialog.css
new file mode 100644
index 00000000..c9eb8011
--- /dev/null
+++ b/FrontEnd/src/pages/setting/ChangeAvatarDialog.css
@@ -0,0 +1,22 @@
+.change-avatar-dialog-prompt {
+ margin: 0.5em 0;
+}
+
+.change-avatar-dialog-prompt.success {
+ color: var(--cru-create-color);
+}
+
+.change-avatar-dialog-prompt.error {
+ color: var(--cru-danger-color);
+}
+
+.change-avatar-cropper {
+ max-width: 400px;
+ max-height: 400px;
+}
+
+.change-avatar-preview-image {
+ min-width: 50%;
+ max-width: 100%;
+ max-height: 300px;
+} \ No newline at end of file
diff --git a/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx b/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx
new file mode 100644
index 00000000..0df10411
--- /dev/null
+++ b/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx
@@ -0,0 +1,276 @@
+import { useState, ChangeEvent, ComponentPropsWithoutRef } from "react";
+
+import { useC, Text, UiLogicError } from "~src/common";
+
+import { useUser } from "~src/services/user";
+
+import { getHttpUserClient } from "~src/http/user";
+
+import { ImageCropper, useImageCrop } from "~src/components/ImageCropper";
+import BlobImage from "~src/components/BlobImage";
+import { ButtonRowV2 } from "~src/components/button";
+import {
+ Dialog,
+ DialogContainer,
+ useDialogController,
+} from "~src/components/dialog";
+
+import "./ChangeAvatarDialog.css";
+
+export default function ChangeAvatarDialog() {
+ const c = useC();
+
+ const user = useUser();
+
+ const controller = useDialogController();
+
+ type State =
+ | "select"
+ | "crop"
+ | "process-crop"
+ | "preview"
+ | "uploading"
+ | "success"
+ | "error";
+ const [state, setState] = useState<State>("select");
+
+ const [file, setFile] = useState<File | null>(null);
+
+ const { canCrop, crop, imageCropperProps } = useImageCrop(file, {
+ constraint: {
+ ratio: 1,
+ },
+ });
+
+ const [resultBlob, setResultBlob] = useState<Blob | null>(null);
+ const [message, setMessage] = useState<Text>(
+ "settings.dialogChangeAvatar.prompt.select",
+ );
+
+ const close = controller.closeDialog;
+
+ const onSelectFile = (e: ChangeEvent<HTMLInputElement>): void => {
+ const files = e.target.files;
+ if (files == null || files.length === 0) {
+ setFile(null);
+ } else {
+ setFile(files[0]);
+ }
+ };
+
+ const onCropNext = () => {
+ if (!canCrop) {
+ throw new UiLogicError();
+ }
+
+ setState("process-crop");
+
+ void crop().then((b) => {
+ setState("preview");
+ setResultBlob(b);
+ });
+ };
+
+ const onCropPrevious = () => {
+ setFile(null);
+ setState("select");
+ };
+
+ const onPreviewPrevious = () => {
+ setState("crop");
+ };
+
+ const upload = () => {
+ if (resultBlob == null) {
+ throw new UiLogicError();
+ }
+
+ if (user == null) {
+ throw new UiLogicError();
+ }
+
+ setState("uploading");
+ controller.setCanSwitchDialog(false);
+ getHttpUserClient()
+ .putAvatar(user.username, resultBlob)
+ .then(
+ () => {
+ setState("success");
+ },
+ () => {
+ setState("error");
+ setMessage("operationDialog.error");
+ },
+ )
+ .finally(() => {
+ controller.setCanSwitchDialog(true);
+ });
+ };
+
+ const cancelButton = {
+ key: "cancel",
+ text: "operationDialog.cancel",
+ onClick: close,
+ } as const;
+
+ const createPreviousButton = (onClick: () => void) =>
+ ({
+ key: "previous",
+ text: "operationDialog.previousStep",
+ onClick,
+ }) as const;
+
+ const buttonsMap: Record<
+ State,
+ ComponentPropsWithoutRef<typeof ButtonRowV2>["buttons"]
+ > = {
+ select: [
+ cancelButton,
+ {
+ key: "next",
+ action: "major",
+ text: "operationDialog.nextStep",
+ onClick: () => setState("crop"),
+ disabled: file == null,
+ },
+ ],
+ crop: [
+ cancelButton,
+ createPreviousButton(onCropPrevious),
+ {
+ key: "next",
+ action: "major",
+ text: "operationDialog.nextStep",
+ onClick: onCropNext,
+ disabled: !canCrop,
+ },
+ ],
+ "process-crop": [cancelButton, createPreviousButton(onPreviewPrevious)],
+ preview: [
+ cancelButton,
+ createPreviousButton(onPreviewPrevious),
+ {
+ key: "upload",
+ action: "major",
+ text: "settings.dialogChangeAvatar.upload",
+ onClick: upload,
+ },
+ ],
+ uploading: [],
+ success: [
+ {
+ key: "ok",
+ text: "operationDialog.ok",
+ color: "create",
+ onClick: close,
+ },
+ ],
+ error: [
+ cancelButton,
+ {
+ key: "retry",
+ action: "major",
+ text: "operationDialog.retry",
+ onClick: upload,
+ },
+ ],
+ };
+
+ return (
+ <Dialog>
+ <DialogContainer
+ title="settings.dialogChangeAvatar.title"
+ titleColor="primary"
+ buttonsV2={buttonsMap[state]}
+ >
+ {(() => {
+ if (state === "select") {
+ return (
+ <div className="change-avatar-dialog-container">
+ <div className="change-avatar-dialog-prompt">
+ {c("settings.dialogChangeAvatar.prompt.select")}
+ </div>
+ <input
+ className="change-avatar-select-input"
+ type="file"
+ accept="image/*"
+ onChange={onSelectFile}
+ />
+ </div>
+ );
+ } else if (state === "crop") {
+ if (file == null) {
+ throw new UiLogicError();
+ }
+ return (
+ <div className="change-avatar-dialog-container">
+ <ImageCropper
+ {...imageCropperProps}
+ containerClassName="change-avatar-cropper"
+ />
+ <div className="change-avatar-dialog-prompt">
+ {c("settings.dialogChangeAvatar.prompt.crop")}
+ </div>
+ </div>
+ );
+ } else if (state === "process-crop") {
+ return (
+ <div className="change-avatar-dialog-container">
+ <div className="change-avatar-dialog-prompt">
+ {c("settings.dialogChangeAvatar.prompt.processingCrop")}
+ </div>
+ </div>
+ );
+ } else if (state === "preview") {
+ return (
+ <div className="change-avatar-dialog-container">
+ <BlobImage
+ className="change-avatar-preview-image"
+ src={resultBlob}
+ alt={
+ c("settings.dialogChangeAvatar.previewImgAlt") ?? undefined
+ }
+ />
+ <div className="change-avatar-dialog-prompt">
+ {c("settings.dialogChangeAvatar.prompt.preview")}
+ </div>
+ </div>
+ );
+ } else if (state === "uploading") {
+ return (
+ <div className="change-avatar-dialog-container">
+ <BlobImage
+ className="change-avatar-preview-image"
+ src={resultBlob}
+ />
+ <div className="change-avatar-dialog-prompt">
+ {c("settings.dialogChangeAvatar.prompt.uploading")}
+ </div>
+ </div>
+ );
+ } else if (state === "success") {
+ return (
+ <div className="change-avatar-dialog-container">
+ <div className="change-avatar-dialog-prompt success">
+ {c("operationDialog.success")}
+ </div>
+ </div>
+ );
+ } else {
+ return (
+ <div className="change-avatar-dialog-container">
+ <BlobImage
+ className="change-avatar-preview-image"
+ src={resultBlob}
+ />
+ <div className="change-avatar-dialog-prompt error">
+ {c(message)}
+ </div>
+ </div>
+ );
+ }
+ })()}
+ </DialogContainer>
+ </Dialog>
+ );
+}
diff --git a/FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx b/FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx
new file mode 100644
index 00000000..912f554f
--- /dev/null
+++ b/FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx
@@ -0,0 +1,26 @@
+import { getHttpUserClient } from "~src/http/user";
+import { useUserLoggedIn } from "~src/services/user";
+
+import { OperationDialog } from "~src/components/dialog";
+
+export default function ChangeNicknameDialog() {
+ const user = useUserLoggedIn();
+
+ return (
+ <OperationDialog
+ title="settings.dialogChangeNickname.title"
+ inputs={[
+ {
+ key: "newNickname",
+ type: "text",
+ label: "settings.dialogChangeNickname.inputLabel",
+ },
+ ]}
+ onProcess={({ newNickname }) => {
+ return getHttpUserClient().patch(user.username, {
+ nickname: newNickname,
+ });
+ }}
+ />
+ );
+}
diff --git a/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx b/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx
new file mode 100644
index 00000000..c3111ac8
--- /dev/null
+++ b/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx
@@ -0,0 +1,70 @@
+import { useState } from "react";
+import { useNavigate } from "react-router-dom";
+
+import { userService } from "~src/services/user";
+
+import { OperationDialog } from "~src/components/dialog";
+
+export function ChangePasswordDialog() {
+ const navigate = useNavigate();
+
+ const [redirect, setRedirect] = useState<boolean>(false);
+
+ return (
+ <OperationDialog
+ title="settings.dialogChangePassword.title"
+ color="danger"
+ inputPrompt="settings.dialogChangePassword.prompt"
+ inputs={{
+ inputs: [
+ {
+ key: "oldPassword",
+ type: "text",
+ label: "settings.dialogChangePassword.inputOldPassword",
+ password: true,
+ },
+ {
+ key: "newPassword",
+ type: "text",
+ label: "settings.dialogChangePassword.inputNewPassword",
+ password: true,
+ },
+ {
+ key: "retypedNewPassword",
+ type: "text",
+ label: "settings.dialogChangePassword.inputRetypeNewPassword",
+ password: true,
+ },
+ ],
+ validator: (
+ { oldPassword, newPassword, retypedNewPassword },
+ errors,
+ ) => {
+ if (oldPassword === "") {
+ errors["oldPassword"] =
+ "settings.dialogChangePassword.errorEmptyOldPassword";
+ }
+ if (newPassword === "") {
+ errors["newPassword"] =
+ "settings.dialogChangePassword.errorEmptyNewPassword";
+ }
+ if (retypedNewPassword !== newPassword) {
+ errors["retypedNewPassword"] =
+ "settings.dialogChangePassword.errorRetypeNotMatch";
+ }
+ },
+ }}
+ onProcess={async ({ oldPassword, newPassword }) => {
+ await userService.changePassword(oldPassword, newPassword);
+ setRedirect(true);
+ }}
+ onSuccessAndClose={() => {
+ if (redirect) {
+ navigate("/login");
+ }
+ }}
+ />
+ );
+}
+
+export default ChangePasswordDialog;
diff --git a/FrontEnd/src/pages/setting/index.css b/FrontEnd/src/pages/setting/index.css
new file mode 100644
index 00000000..19e7cff4
--- /dev/null
+++ b/FrontEnd/src/pages/setting/index.css
@@ -0,0 +1,76 @@
+.setting-section {
+ padding: 1em 0;
+ margin: 1em 0;
+}
+
+.setting-section-title {
+ padding: 0 1em;
+}
+
+.setting-section-item-area {
+ margin-top: 1em;
+ border-top: 1px solid var(--cru-primary-color);
+}
+
+.setting-item-container {
+ padding: 0.5em 1em;
+ transition: background-color 0.3s;
+ background-color: var(--cru-clickable-grayscale-normal-color);
+ border-bottom: 1px solid var(--cru-clickable-grayscale-active-color);
+ display: flex;
+ align-items: center;
+}
+
+.setting-item-container:hover {
+ background-color: var(--cru-clickable-grayscale-hover-color);
+}
+
+.setting-item-container:focus {
+ background-color: var(--cru-clickable-grayscale-focus-color);
+}
+
+.setting-item-container:active {
+ background-color: var(--cru-clickable-grayscale-active-color);
+}
+
+.setting-item-container.danger {
+ color: var(--cru-danger-color);
+}
+
+.setting-item-label-sub {
+ color: var(--cru-text-minor-color);
+}
+
+.setting-item-value-area {
+ margin-left: auto;
+}
+
+.setting-item-container.setting-type-button {
+ cursor: pointer;
+}
+
+.register-code {
+ background: var(--cru-text-major-color);
+ color: var(--cru-background-color);
+ border-radius: 3px;
+ padding: 0.2em;
+ cursor: pointer;
+}
+
+@media (max-width: 576) {
+ .setting-item-container.setting-type-select {
+ flex-direction: column;
+ }
+
+ .setting-item-container.setting-type-select .setting-item-value-area {
+ margin-top: 1em;
+ }
+
+ .register-code-setting-item {
+ flex-direction: column;
+ }
+
+ .register-code-setting-item .register-code {
+ margin-top: 1em;
+ }
+} \ No newline at end of file
diff --git a/FrontEnd/src/pages/setting/index.tsx b/FrontEnd/src/pages/setting/index.tsx
new file mode 100644
index 00000000..88ab5cb2
--- /dev/null
+++ b/FrontEnd/src/pages/setting/index.tsx
@@ -0,0 +1,297 @@
+import {
+ useState,
+ useEffect,
+ ReactNode,
+ ComponentPropsWithoutRef,
+} from "react";
+import { useTranslation } from "react-i18next"; // For change language.
+import { useNavigate } from "react-router-dom";
+import classNames from "classnames";
+
+import { useUser, userService } from "~src/services/user";
+import { getHttpUserClient } from "~src/http/user";
+
+import { useC, Text } from "~src/common";
+
+import { pushAlert } from "~src/components/alert";
+import {
+ useDialog,
+ DialogProvider,
+ ConfirmDialog,
+} from "~src/components/dialog";
+import Card from "~src/components/Card";
+import Spinner from "~src/components/Spinner";
+import Page from "~src/components/Page";
+
+import ChangePasswordDialog from "./ChangePasswordDialog";
+import ChangeAvatarDialog from "./ChangeAvatarDialog";
+import ChangeNicknameDialog from "./ChangeNicknameDialog";
+
+import "./index.css";
+
+interface SettingSectionProps
+ extends Omit<ComponentPropsWithoutRef<typeof Card>, "title"> {
+ title: Text;
+ children?: ReactNode;
+}
+
+function SettingSection({
+ title,
+ className,
+ children,
+ ...otherProps
+}: SettingSectionProps) {
+ const c = useC();
+
+ return (
+ <Card className={classNames(className, "setting-section")} {...otherProps}>
+ <h2 className="setting-section-title">{c(title)}</h2>
+ <div className="setting-section-item-area">{children}</div>
+ </Card>
+ );
+}
+
+interface SettingItemContainerProps
+ extends Omit<ComponentPropsWithoutRef<"div">, "title"> {
+ title: Text;
+ description?: Text;
+ danger?: boolean;
+ extraClassName?: string;
+}
+
+function SettingItemContainer({
+ title,
+ description,
+ danger,
+ extraClassName,
+ className,
+ children,
+ ...otherProps
+}: SettingItemContainerProps) {
+ const c = useC();
+
+ return (
+ <div
+ className={classNames(
+ className,
+ "setting-item-container",
+ danger && "danger",
+ extraClassName,
+ )}
+ {...otherProps}
+ >
+ <div className="setting-item-label-area">
+ <div className="setting-item-label-title">{c(title)}</div>
+ <small className="setting-item-label-sub">{c(description)}</small>
+ </div>
+ <div className="setting-item-value-area">{children}</div>
+ </div>
+ );
+}
+
+type ButtonSettingItemProps = Omit<SettingItemContainerProps, "extraClassName">;
+
+function ButtonSettingItem(props: ButtonSettingItemProps) {
+ return (
+ <SettingItemContainer extraClassName="setting-type-button" {...props} />
+ );
+}
+
+interface SelectSettingItemProps
+ extends Omit<SettingItemContainerProps, "onSelect" | "extraClassName"> {
+ options: {
+ value: string;
+ label: Text;
+ }[];
+ value?: string | null;
+ onSelect: (value: string) => void;
+}
+
+function SelectSettingsItem({
+ options,
+ value,
+ onSelect,
+ ...extraProps
+}: SelectSettingItemProps) {
+ const c = useC();
+
+ return (
+ <SettingItemContainer extraClassName="setting-type-select" {...extraProps}>
+ {value == null ? (
+ <Spinner />
+ ) : (
+ <select
+ className="select-setting-item-select"
+ value={value}
+ onChange={(e) => {
+ onSelect(e.target.value);
+ }}
+ >
+ {options.map(({ value, label }) => (
+ <option key={value} value={value}>
+ {c(label)}
+ </option>
+ ))}
+ </select>
+ )}
+ </SettingItemContainer>
+ );
+}
+
+function RegisterCodeSettingItem() {
+ const user = useUser();
+
+ // undefined: loading
+ const [registerCode, setRegisterCode] = useState<undefined | null | string>();
+
+ const { controller, createDialogSwitch } = useDialog({
+ confirm: (
+ <ConfirmDialog
+ title="settings.renewRegisterCode"
+ body="settings.renewRegisterCodeDesc"
+ onConfirm={() => {
+ if (user == null) throw new Error();
+ void getHttpUserClient()
+ .renewRegisterCode(user.username)
+ .then(() => {
+ setRegisterCode(undefined);
+ });
+ }}
+ />
+ ),
+ });
+
+ useEffect(() => {
+ setRegisterCode(undefined);
+ }, [user]);
+
+ useEffect(() => {
+ if (user != null && registerCode === undefined) {
+ void getHttpUserClient()
+ .getRegisterCode(user.username)
+ .then((code) => {
+ setRegisterCode(code.registerCode ?? null);
+ });
+ }
+ }, [user, registerCode]);
+
+ return (
+ <>
+ <SettingItemContainer
+ title="settings.myRegisterCode"
+ description="settings.myRegisterCodeDesc"
+ className="register-code-setting-item"
+ onClick={createDialogSwitch("confirm")}
+ >
+ {registerCode === undefined ? (
+ <Spinner />
+ ) : registerCode === null ? (
+ <span>Noop</span>
+ ) : (
+ <code
+ className="register-code"
+ onClick={(event) => {
+ void navigator.clipboard.writeText(registerCode).then(() => {
+ pushAlert({
+ color: "create",
+ message: "settings.myRegisterCodeCopied",
+ });
+ });
+ event.stopPropagation();
+ }}
+ >
+ {registerCode}
+ </code>
+ )}
+ </SettingItemContainer>
+ <DialogProvider controller={controller} />
+ </>
+ );
+}
+
+function LanguageChangeSettingItem() {
+ const { i18n } = useTranslation();
+
+ const language = i18n.language.slice(0, 2);
+
+ return (
+ <SelectSettingsItem
+ title="settings.languagePrimary"
+ description="settings.languageSecondary"
+ options={[
+ {
+ value: "zh",
+ label: {
+ type: "custom",
+ value: "中文",
+ },
+ },
+ {
+ value: "en",
+ label: {
+ type: "custom",
+ value: "English",
+ },
+ },
+ ]}
+ value={language}
+ onSelect={(value) => {
+ void i18n.changeLanguage(value);
+ }}
+ />
+ );
+}
+
+export default function SettingPage() {
+ const user = useUser();
+ const navigate = useNavigate();
+
+ const { controller, createDialogSwitch } = useDialog({
+ "change-nickname": <ChangeNicknameDialog />,
+ "change-avatar": <ChangeAvatarDialog />,
+ "change-password": <ChangePasswordDialog />,
+ logout: (
+ <ConfirmDialog
+ title="settings.dialogConfirmLogout.title"
+ body="settings.dialogConfirmLogout.prompt"
+ onConfirm={() => {
+ void userService.logout().then(() => {
+ navigate("/");
+ });
+ }}
+ />
+ ),
+ });
+
+ return (
+ <Page noTopPadding>
+ {user ? (
+ <SettingSection title="settings.subheader.account">
+ <RegisterCodeSettingItem />
+ <ButtonSettingItem
+ title="settings.changeAvatar"
+ onClick={createDialogSwitch("change-avatar")}
+ />
+ <ButtonSettingItem
+ title="settings.changeNickname"
+ onClick={createDialogSwitch("change-nickname")}
+ />
+ <ButtonSettingItem
+ title="settings.changePassword"
+ onClick={createDialogSwitch("change-password")}
+ danger
+ />
+ <ButtonSettingItem
+ title="settings.logout"
+ onClick={createDialogSwitch("logout")}
+ danger
+ />
+ </SettingSection>
+ ) : null}
+ <SettingSection title="settings.subheader.customization">
+ <LanguageChangeSettingItem />
+ </SettingSection>
+ <DialogProvider controller={controller} />
+ </Page>
+ );
+}