From 0e183074b326cf04a23ae1f1ba8dcc56166df485 Mon Sep 17 00:00:00 2001 From: crupest Date: Thu, 20 Jul 2023 20:44:15 +0800 Subject: ... --- FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx | 354 +++++++++++++++++++++ .../src/pages/setting/ChangeNicknameDialog.tsx | 34 ++ .../src/pages/setting/ChangePasswordDialog.tsx | 69 ++++ FrontEnd/src/pages/setting/index.css | 31 ++ FrontEnd/src/pages/setting/index.tsx | 335 +++++++++++++++++++ 5 files changed, 823 insertions(+) create mode 100644 FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx create mode 100644 FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx create mode 100644 FrontEnd/src/pages/setting/ChangePasswordDialog.tsx create mode 100644 FrontEnd/src/pages/setting/index.css create mode 100644 FrontEnd/src/pages/setting/index.tsx (limited to 'FrontEnd/src/pages/setting') diff --git a/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx b/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx new file mode 100644 index 00000000..44bd2c68 --- /dev/null +++ b/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx @@ -0,0 +1,354 @@ +import { useState, useEffect } from "react"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { AxiosError } from "axios"; + +import { convertI18nText, I18nText, UiLogicError } from "@/common"; + +import { useUser } from "@/services/user"; + +import { getHttpUserClient } from "@/http/user"; + +import ImageCropper, { Clip, applyClipToImage } from "../common/ImageCropper"; +import Button from "../common/button/Button"; +import Dialog from "../common/dialog/Dialog"; + +export interface ChangeAvatarDialogProps { + open: boolean; + close: () => void; +} + +const ChangeAvatarDialog: React.FC = (props) => { + const { t } = useTranslation(); + + const user = useUser(); + + 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( + "settings.dialogChangeAvatar.prompt.select" + ); + + const trueMessage = convertI18nText(message, t); + + 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(); + } + + if (user == null) { + throw new UiLogicError(); + } + + setState("uploading"); + getHttpUserClient() + .putAvatar(user.username, resultBlob) + .then( + () => { + setState("success"); + }, + (e: unknown) => { + setState("error"); + setMessage({ type: "custom", value: (e as AxiosError).message }); + } + ); + }, [user, 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/pages/setting/ChangeNicknameDialog.tsx b/FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx new file mode 100644 index 00000000..7ba12de8 --- /dev/null +++ b/FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx @@ -0,0 +1,34 @@ +import { getHttpUserClient } from "@/http/user"; +import { useUser } from "@/services/user"; +import * as React from "react"; + +import OperationDialog from "../common/dialog/OperationDialog"; + +export interface ChangeNicknameDialogProps { + open: boolean; + close: () => void; +} + +const ChangeNicknameDialog: React.FC = (props) => { + const user = useUser(); + + if (user == null) return null; + + return ( + { + return getHttpUserClient().patch(user.username, { + nickname: newNickname, + }); + }} + onClose={props.close} + /> + ); +}; + +export default ChangeNicknameDialog; diff --git a/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx b/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx new file mode 100644 index 00000000..a34ca4a7 --- /dev/null +++ b/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx @@ -0,0 +1,69 @@ +import { useState } from "react"; +import * as React from "react"; +import { useNavigate } from "react-router-dom"; + +import { userService } from "@/services/user"; + +import OperationDialog from "../common/dialog/OperationDialog"; + +export interface ChangePasswordDialogProps { + open: boolean; + close: () => void; +} + +const ChangePasswordDialog: React.FC = (props) => { + const navigate = useNavigate(); + + 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); + }} + onClose={() => { + props.close(); + 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..ccf7a97a --- /dev/null +++ b/FrontEnd/src/pages/setting/index.css @@ -0,0 +1,31 @@ +.change-avatar-cropper-row { + max-height: 400px; +} + +.change-avatar-img { + min-width: 50%; + max-width: 100%; + max-height: 400px; +} + +.settings-item { + padding: 0.5em 1em; + transition: background 0.3s; + border-bottom: 1px solid #e9ecef; + align-items: center; +} +.settings-item.first { + border-top: 1px solid #e9ecef; +} +.settings-item.clickable { + cursor: pointer; +} +.settings-item:hover { + background: #dee2e6; +} + +.register-code { + border: 1px solid black; + border-radius: 3px; + padding: 0.2em; +} \ 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..00503dcf --- /dev/null +++ b/FrontEnd/src/pages/setting/index.tsx @@ -0,0 +1,335 @@ +import { useState, ReactNode } from "react"; +import { useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import classNames from "classnames"; + +import { useC, I18nText } from "@/common"; +import { useUser, userService } from "@/services/user"; +import { getHttpUserClient } from "@/http/user"; +import { TimelineVisibility } from "@/http/timeline"; + +import ConfirmDialog from "../common/dialog/ConfirmDialog"; +import Card from "../common/Card"; +import Spinner from "../common/Spinner"; +import ChangePasswordDialog from "./ChangePasswordDialog"; +import ChangeAvatarDialog from "./ChangeAvatarDialog"; +import ChangeNicknameDialog from "./ChangeNicknameDialog"; + +import "./index.css"; +import { pushAlert } from "@/services/alert"; + +interface SettingSectionProps { + title: I18nText; + children: ReactNode; +} + +function SettingSection({ title, children }: SettingSectionProps) { + const c = useC(); + + return ( + +

{c(title)}

+ {children} +
+ ); +} + +interface SettingItemContainerWithoutChildrenProps { + title: I18nText; + subtext?: I18nText; + first?: boolean; + danger?: boolean; + style?: React.CSSProperties; + className?: string; + onClick?: () => void; +} + +interface SettingItemContainerProps + extends SettingItemContainerWithoutChildrenProps { + children?: React.ReactNode; +} + +function SettingItemContainer({ + title, + subtext, + first, + danger, + children, + style, + className, + onClick, +}: SettingItemContainerProps): JSX.Element { + const { t } = useTranslation(); + + return ( +
+
+
+ {convertI18nText(title, t)} +
+ + {convertI18nText(subtext, t)} + +
+
{children}
+
+ ); +} + +type ButtonSettingItemProps = SettingItemContainerWithoutChildrenProps; + +const ButtonSettingItem: React.FC = ({ ...props }) => { + return ; +}; + +interface SelectSettingItemProps + extends SettingItemContainerWithoutChildrenProps { + options: { + value: string; + label: I18nText; + }[]; + value?: string; + onSelect: (value: string) => void; +} + +const SelectSettingsItem: React.FC = ({ + options, + value, + onSelect, + ...props +}) => { + const { t } = useTranslation(); + + return ( + + {value == null ? ( + + ) : ( + + )} + + ); +}; + +const SettingsPage: React.FC = () => { + const { i18n } = useTranslation(); + const user = useUser(); + const navigate = useNavigate(); + + const [dialog, setDialog] = useState< + | null + | "changepassword" + | "changeavatar" + | "changenickname" + | "logout" + | "renewregistercode" + >(null); + + const [registerCode, setRegisterCode] = useState( + undefined, + ); + + const [bookmarkVisibility, setBookmarkVisibility] = + useState(); + + React.useEffect(() => { + if (user != null) { + void getHttpUserClient() + .getBookmarkVisibility(user.username) + .then(({ visibility }) => { + setBookmarkVisibility(visibility); + }); + } else { + setBookmarkVisibility(undefined); + } + }, [user]); + + React.useEffect(() => { + setRegisterCode(undefined); + }, [user]); + + React.useEffect(() => { + if (user != null && registerCode === undefined) { + void getHttpUserClient() + .getRegisterCode(user.username) + .then((code) => { + setRegisterCode(code.registerCode ?? null); + }); + } + }, [user, registerCode]); + + const language = i18n.language.slice(0, 2); + + return ( + <> +
+ {user ? ( + + setDialog("renewregistercode")} + > + {registerCode === undefined ? ( + + ) : registerCode === null ? ( + Noop + ) : ( + { + void navigator.clipboard + .writeText(registerCode) + .then(() => { + pushAlert({ + type: "success", + message: "settings.myRegisterCodeCopied", + }); + }); + event.stopPropagation(); + }} + > + {registerCode} + + )} + + setDialog("changeavatar")} + first + /> + setDialog("changenickname")} + /> + { + void getHttpUserClient() + .putBookmarkVisibility(user.username, { + visibility: value as TimelineVisibility, + }) + .then(() => { + setBookmarkVisibility(value as TimelineVisibility); + }); + }} + /> + setDialog("changepassword")} + danger + /> + { + setDialog("logout"); + }} + danger + /> + + ) : null} + + { + void i18n.changeLanguage(value); + }} + first + /> + +
+ setDialog(null)} + /> + setDialog(null)} + open={dialog === "logout"} + onConfirm={() => { + void userService.logout().then(() => { + navigate("/"); + }); + }} + /> + setDialog(null)} + open={dialog === "renewregistercode"} + onConfirm={() => { + if (user == null) throw new UiLogicError(); + void getHttpUserClient() + .renewRegisterCode(user.username) + .then(() => { + setRegisterCode(undefined); + }); + }} + /> + setDialog(null)} + /> + setDialog(null)} + /> + + ); +}; + +export default SettingsPage; -- cgit v1.2.3 From 2226efed8c8604a938d060d62565b611722e837c Mon Sep 17 00:00:00 2001 From: crupest Date: Thu, 20 Jul 2023 23:01:58 +0800 Subject: ... --- FrontEnd/src/common.ts | 1 + FrontEnd/src/locales/en/translation.json | 3 +- FrontEnd/src/locales/zh/translation.json | 2 +- FrontEnd/src/pages/setting/index.tsx | 279 +++++++++++------------- FrontEnd/src/views/common/Card.tsx | 2 +- FrontEnd/src/views/common/button/Button.tsx | 4 +- FrontEnd/src/views/common/button/FlatButton.tsx | 4 +- FrontEnd/src/views/common/common.ts | 2 +- 8 files changed, 133 insertions(+), 164 deletions(-) (limited to 'FrontEnd/src/pages/setting') diff --git a/FrontEnd/src/common.ts b/FrontEnd/src/common.ts index 965f9933..7c053140 100644 --- a/FrontEnd/src/common.ts +++ b/FrontEnd/src/common.ts @@ -6,5 +6,6 @@ export class UiLogicError extends Error {} export const highlightTimelineUsername = "crupest"; export type { I18nText } from "./i18n"; +export type { I18nText as Text } from "./i18n"; export { c, convertI18nText } from "./i18n"; export { default as useC } from "./utilities/hooks/use-c"; diff --git a/FrontEnd/src/locales/en/translation.json b/FrontEnd/src/locales/en/translation.json index 95c722c9..a73472d2 100644 --- a/FrontEnd/src/locales/en/translation.json +++ b/FrontEnd/src/locales/en/translation.json @@ -176,7 +176,7 @@ "noAccount": "If you don't have an account and know a register code, then click <1>here to register." }, "settings": { - "subheaders": { + "subheader": { "account": "Account", "customization": "Customization" }, @@ -186,7 +186,6 @@ "logout": "Log out this account", "changeAvatar": "Change avatar", "changeNickname": "Change nickname", - "changeBookmarkVisibility": "Change bookmark visibility", "myRegisterCode": "My register code:", "myRegisterCodeDesc": "Click to create a new register code.", "renewRegisterCode": "Renew Register Code", diff --git a/FrontEnd/src/locales/zh/translation.json b/FrontEnd/src/locales/zh/translation.json index b7212128..8a2f628f 100644 --- a/FrontEnd/src/locales/zh/translation.json +++ b/FrontEnd/src/locales/zh/translation.json @@ -176,7 +176,7 @@ "noAccount": "如果你没有账号但有一个注册码,请点击<1>这里注册账号。" }, "settings": { - "subheaders": { + "subheader": { "account": "账户", "customization": "个性化" }, diff --git a/FrontEnd/src/pages/setting/index.tsx b/FrontEnd/src/pages/setting/index.tsx index 00503dcf..4e28585e 100644 --- a/FrontEnd/src/pages/setting/index.tsx +++ b/FrontEnd/src/pages/setting/index.tsx @@ -1,16 +1,21 @@ -import { useState, ReactNode } from "react"; -import { useNavigate } from "react-router-dom"; +import { + useState, + useEffect, + ReactNode, + ComponentPropsWithoutRef, +} from "react"; import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; import classNames from "classnames"; -import { useC, I18nText } from "@/common"; +import { useC, Text } from "@/common"; import { useUser, userService } from "@/services/user"; import { getHttpUserClient } from "@/http/user"; import { TimelineVisibility } from "@/http/timeline"; -import ConfirmDialog from "../common/dialog/ConfirmDialog"; -import Card from "../common/Card"; -import Spinner from "../common/Spinner"; +import ConfirmDialog from "@/views/common/dialog/ConfirmDialog"; +import Card from "@/views/common/Card"; +import Spinner from "@/views/common/Spinner"; import ChangePasswordDialog from "./ChangePasswordDialog"; import ChangeAvatarDialog from "./ChangeAvatarDialog"; import ChangeNicknameDialog from "./ChangeNicknameDialog"; @@ -18,99 +23,94 @@ import ChangeNicknameDialog from "./ChangeNicknameDialog"; import "./index.css"; import { pushAlert } from "@/services/alert"; -interface SettingSectionProps { - title: I18nText; +interface SettingSectionProps + extends Omit, "title"> { + title: Text; children: ReactNode; } -function SettingSection({ title, children }: SettingSectionProps) { +function SettingSection({ + title, + className, + children, + ...otherProps +}: SettingSectionProps) { const c = useC(); return ( - -

{c(title)}

+ +

{c(title)}

{children}
); } -interface SettingItemContainerWithoutChildrenProps { - title: I18nText; - subtext?: I18nText; - first?: boolean; - danger?: boolean; - style?: React.CSSProperties; - className?: string; - onClick?: () => void; -} - interface SettingItemContainerProps - extends SettingItemContainerWithoutChildrenProps { - children?: React.ReactNode; + extends Omit, "title"> { + title: Text; + description?: Text; + danger?: boolean; + extraClassName?: string; } function SettingItemContainer({ title, - subtext, - first, + description, danger, - children, - style, + extraClassName, className, - onClick, -}: SettingItemContainerProps): JSX.Element { - const { t } = useTranslation(); + children, + ...otherProps +}: SettingItemContainerProps) { + const c = useC(); return (
-
-
- {convertI18nText(title, t)} -
- - {convertI18nText(subtext, t)} - +
+
{c(title)}
+ {c(description)}
-
{children}
+
{children}
); } -type ButtonSettingItemProps = SettingItemContainerWithoutChildrenProps; +type ButtonSettingItemProps = Omit; -const ButtonSettingItem: React.FC = ({ ...props }) => { - return ; -}; +function ButtonSettingItem(props: ButtonSettingItemProps) { + return ( + + ); +} interface SelectSettingItemProps - extends SettingItemContainerWithoutChildrenProps { + extends Omit { options: { value: string; - label: I18nText; + label: Text; }[]; - value?: string; + value?: string | null; onSelect: (value: string) => void; } -const SelectSettingsItem: React.FC = ({ +function SelectSettingsItem({ options, value, onSelect, - ...props -}) => { - const { t } = useTranslation(); + ...extraProps +}: SelectSettingItemProps) { + const c = useC(); return ( - + {value == null ? ( ) : ( @@ -122,53 +122,30 @@ const SelectSettingsItem: React.FC = ({ > {options.map(({ value, label }) => ( ))} )} ); -}; +} -const SettingsPage: React.FC = () => { - const { i18n } = useTranslation(); +function RegisterCodeSettingItem({ + openRenewDialog, +}: { + openRenewDialog: () => void; +}) { const user = useUser(); - const navigate = useNavigate(); - - const [dialog, setDialog] = useState< - | null - | "changepassword" - | "changeavatar" - | "changenickname" - | "logout" - | "renewregistercode" - >(null); - const [registerCode, setRegisterCode] = useState( - undefined, - ); - - const [bookmarkVisibility, setBookmarkVisibility] = - useState(); - - React.useEffect(() => { - if (user != null) { - void getHttpUserClient() - .getBookmarkVisibility(user.username) - .then(({ visibility }) => { - setBookmarkVisibility(visibility); - }); - } else { - setBookmarkVisibility(undefined); - } - }, [user]); + // undefined: loading + const [registerCode, setRegisterCode] = useState(); - React.useEffect(() => { + useEffect(() => { setRegisterCode(undefined); }, [user]); - React.useEffect(() => { + useEffect(() => { if (user != null && registerCode === undefined) { void getHttpUserClient() .getRegisterCode(user.username) @@ -178,87 +155,81 @@ const SettingsPage: React.FC = () => { } }, [user, registerCode]); + return ( + + {registerCode === undefined ? ( + + ) : registerCode === null ? ( + Noop + ) : ( + { + void navigator.clipboard.writeText(registerCode).then(() => { + pushAlert({ + type: "success", + message: "settings.myRegisterCodeCopied", + }); + }); + event.stopPropagation(); + }} + > + {registerCode} + + )} + + ); +} + +export default function SettingsPage() { + const c = useC(); + const { i18n } = useTranslation(); + const user = useUser(); + const navigate = useNavigate(); + + type DialogName = + | "change-password" + | "change-avatar" + | "change-nickname" + | "logout" + | "renew-register-code"; + + const [dialog, setDialog] = useState(null); + + function dialogOpener(name: DialogName): () => void { + return () => setDialog(name); + } + const language = i18n.language.slice(0, 2); return ( <>
{user ? ( - - setDialog("renewregistercode")} - > - {registerCode === undefined ? ( - - ) : registerCode === null ? ( - Noop - ) : ( - { - void navigator.clipboard - .writeText(registerCode) - .then(() => { - pushAlert({ - type: "success", - message: "settings.myRegisterCodeCopied", - }); - }); - event.stopPropagation(); - }} - > - {registerCode} - - )} - + + setDialog("changeavatar")} - first + onClick={dialogOpener("change-avatar")} /> setDialog("changenickname")} - /> - { - void getHttpUserClient() - .putBookmarkVisibility(user.username, { - visibility: value as TimelineVisibility, - }) - .then(() => { - setBookmarkVisibility(value as TimelineVisibility); - }); - }} + onClick={dialogOpener("change-nickname")} /> setDialog("changepassword")} + onClick={dialogOpener("change-password")} danger /> { - setDialog("logout"); - }} + onClick={dialogOpener("logout")} danger /> @@ -330,6 +301,4 @@ const SettingsPage: React.FC = () => { /> ); -}; - -export default SettingsPage; +} diff --git a/FrontEnd/src/views/common/Card.tsx b/FrontEnd/src/views/common/Card.tsx index 50632006..5ff89b61 100644 --- a/FrontEnd/src/views/common/Card.tsx +++ b/FrontEnd/src/views/common/Card.tsx @@ -4,7 +4,7 @@ import classNames from "classnames"; import "./Card.css"; interface CardProps extends ComponentPropsWithoutRef<"div"> { - containerRef: Ref; + containerRef?: Ref | null; } export default function Card({ diff --git a/FrontEnd/src/views/common/button/Button.tsx b/FrontEnd/src/views/common/button/Button.tsx index e1015f71..0f1bbf2b 100644 --- a/FrontEnd/src/views/common/button/Button.tsx +++ b/FrontEnd/src/views/common/button/Button.tsx @@ -1,13 +1,13 @@ import { ComponentPropsWithoutRef, Ref } from "react"; import classNames from "classnames"; -import { I18nText, useC, ThemeColor } from "../common"; +import { Text, useC, ThemeColor } from "../common"; import "./Button.css"; interface ButtonProps extends ComponentPropsWithoutRef<"button"> { color?: ThemeColor; - text?: I18nText; + text?: Text; outline?: boolean; buttonRef?: Ref | null; } diff --git a/FrontEnd/src/views/common/button/FlatButton.tsx b/FrontEnd/src/views/common/button/FlatButton.tsx index 7b268b6d..ed01f613 100644 --- a/FrontEnd/src/views/common/button/FlatButton.tsx +++ b/FrontEnd/src/views/common/button/FlatButton.tsx @@ -1,13 +1,13 @@ import { ComponentPropsWithoutRef, Ref } from "react"; import classNames from "classnames"; -import { I18nText, useC, ThemeColor } from "../common"; +import { Text, useC, ThemeColor } from "../common"; import "./FlatButton.css"; interface FlatButtonProps extends ComponentPropsWithoutRef<"button"> { color?: ThemeColor; - text?: I18nText; + text?: Text; buttonRef?: Ref | null; } diff --git a/FrontEnd/src/views/common/common.ts b/FrontEnd/src/views/common/common.ts index d3db9f93..4ad41edc 100644 --- a/FrontEnd/src/views/common/common.ts +++ b/FrontEnd/src/views/common/common.ts @@ -1,4 +1,4 @@ -export type { I18nText } from "@/common"; +export type { Text, I18nText } from "@/common"; export { c, convertI18nText, useC } from "@/common"; export const themeColors = [ -- cgit v1.2.3 From d7b050ef7f047f841ffcda96fbdc9857574e97b9 Mon Sep 17 00:00:00 2001 From: crupest Date: Fri, 21 Jul 2023 17:02:05 +0800 Subject: ... --- FrontEnd/src/App.tsx | 8 +- FrontEnd/src/index.css | 1 - FrontEnd/src/pages/about/index.css | 1 - FrontEnd/src/pages/about/index.tsx | 5 +- FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx | 6 +- .../src/pages/setting/ChangeNicknameDialog.tsx | 2 +- .../src/pages/setting/ChangePasswordDialog.tsx | 2 +- FrontEnd/src/pages/setting/index.css | 54 +++- FrontEnd/src/pages/setting/index.tsx | 171 +++++----- FrontEnd/src/views/common/Card.css | 8 +- FrontEnd/src/views/common/Card.tsx | 8 +- FrontEnd/src/views/common/Page.tsx | 15 + FrontEnd/src/views/common/index.css | 8 + FrontEnd/src/views/common/theme.css | 5 +- FrontEnd/src/views/settings/ChangeAvatarDialog.tsx | 354 --------------------- .../src/views/settings/ChangeNicknameDialog.tsx | 34 -- .../src/views/settings/ChangePasswordDialog.tsx | 69 ---- FrontEnd/src/views/settings/index.css | 31 -- FrontEnd/src/views/settings/index.tsx | 338 -------------------- 19 files changed, 182 insertions(+), 938 deletions(-) create mode 100644 FrontEnd/src/views/common/Page.tsx delete mode 100644 FrontEnd/src/views/settings/ChangeAvatarDialog.tsx delete mode 100644 FrontEnd/src/views/settings/ChangeNicknameDialog.tsx delete mode 100644 FrontEnd/src/views/settings/ChangePasswordDialog.tsx delete mode 100644 FrontEnd/src/views/settings/index.css delete mode 100644 FrontEnd/src/views/settings/index.tsx (limited to 'FrontEnd/src/pages/setting') diff --git a/FrontEnd/src/App.tsx b/FrontEnd/src/App.tsx index 07a8780f..92fe0652 100644 --- a/FrontEnd/src/App.tsx +++ b/FrontEnd/src/App.tsx @@ -4,11 +4,11 @@ import { BrowserRouter, Route, Routes } from "react-router-dom"; import AppBar from "./views/common/AppBar"; import NotFoundPage from "./pages/404"; import LoadingPage from "./views/common/LoadingPage"; -import About from "./pages/about"; +import AboutPage from "./pages/about"; +import SettingPage from "./pages/setting"; import Center from "./views/center"; import Login from "./views/login"; import Register from "./views/register"; -import Settings from "./views/settings"; import TimelinePage from "./views/timeline"; import Search from "./views/search"; import Admin from "./views/admin"; @@ -24,8 +24,8 @@ export default function App() { } /> } /> } /> - } /> - } /> + } /> + } /> } /> } /> } /> diff --git a/FrontEnd/src/index.css b/FrontEnd/src/index.css index 3478db05..49791c23 100644 --- a/FrontEnd/src/index.css +++ b/FrontEnd/src/index.css @@ -1,4 +1,3 @@ -@import "npm:bootstrap/dist/css/bootstrap-grid.css"; @import "npm:bootstrap-icons/font/bootstrap-icons.css"; @import "./views/common/index.css"; diff --git a/FrontEnd/src/pages/about/index.css b/FrontEnd/src/pages/about/index.css index 487f4a0a..1ce7a7c8 100644 --- a/FrontEnd/src/pages/about/index.css +++ b/FrontEnd/src/pages/about/index.css @@ -1,5 +1,4 @@ .about-page { - padding: 1em 2em; line-height: 1.5; } diff --git a/FrontEnd/src/pages/about/index.tsx b/FrontEnd/src/pages/about/index.tsx index afd4de34..acec1735 100644 --- a/FrontEnd/src/pages/about/index.tsx +++ b/FrontEnd/src/pages/about/index.tsx @@ -1,6 +1,7 @@ import "./index.css"; import { useC } from "@/common"; +import Page from "@/views/common/Page"; interface Credit { name: string; @@ -52,7 +53,7 @@ export default function AboutPage() { const c = useC(); return ( -
+

{c("about.credits.title")}

{c("about.credits.content")}

{c("about.credits.frontend")}

@@ -81,6 +82,6 @@ export default function AboutPage() { })}
  • ...
  • -
    + ); } diff --git a/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx b/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx index 44bd2c68..b2a4e2a8 100644 --- a/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx +++ b/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx @@ -9,9 +9,9 @@ import { useUser } from "@/services/user"; import { getHttpUserClient } from "@/http/user"; -import ImageCropper, { Clip, applyClipToImage } from "../common/ImageCropper"; -import Button from "../common/button/Button"; -import Dialog from "../common/dialog/Dialog"; +import ImageCropper, { Clip, applyClipToImage } from "@/views/common/ImageCropper"; +import Button from "@/views/common/button/Button"; +import Dialog from "@/views/common/dialog/Dialog"; export interface ChangeAvatarDialogProps { open: boolean; diff --git a/FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx b/FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx index 7ba12de8..11c86222 100644 --- a/FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx +++ b/FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx @@ -2,7 +2,7 @@ import { getHttpUserClient } from "@/http/user"; import { useUser } from "@/services/user"; import * as React from "react"; -import OperationDialog from "../common/dialog/OperationDialog"; +import OperationDialog from "@/views/common/dialog/OperationDialog"; export interface ChangeNicknameDialogProps { open: boolean; diff --git a/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx b/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx index a34ca4a7..a523b454 100644 --- a/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx +++ b/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx @@ -4,7 +4,7 @@ import { useNavigate } from "react-router-dom"; import { userService } from "@/services/user"; -import OperationDialog from "../common/dialog/OperationDialog"; +import OperationDialog from "@/views/common/dialog/OperationDialog"; export interface ChangePasswordDialogProps { open: boolean; diff --git a/FrontEnd/src/pages/setting/index.css b/FrontEnd/src/pages/setting/index.css index ccf7a97a..af5ccf20 100644 --- a/FrontEnd/src/pages/setting/index.css +++ b/FrontEnd/src/pages/setting/index.css @@ -1,3 +1,5 @@ +/* TODO: Make item prettier. */ + .change-avatar-cropper-row { max-height: 400px; } @@ -8,20 +10,56 @@ max-height: 400px; } -.settings-item { +.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-key-container-color); +} + +.setting-item-container { padding: 0.5em 1em; - transition: background 0.3s; - border-bottom: 1px solid #e9ecef; + transition: background-color 0.3s; + color: var(--cru-surface-on-color); + background-color: var(--cru-surface-color); + border-bottom: 1px solid var(--cru-key-container-color); + display: flex; align-items: center; } -.settings-item.first { - border-top: 1px solid #e9ecef; + +.setting-item-container:hover { + background-color: var(--cru-key-container-1-color); + border-bottom-color: var(--cru-key-container-1-color); +} + +.setting-item-label-sub { + color: var(--cru-secondary-text-color); } -.settings-item.clickable { + +.setting-item-container.setting-type-button { cursor: pointer; } -.settings-item:hover { - background: #dee2e6; + +.setting-item-container.setting-type-button.danger { + color: var(--cru-danger-color); +} + + +@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 { diff --git a/FrontEnd/src/pages/setting/index.tsx b/FrontEnd/src/pages/setting/index.tsx index 4e28585e..5d6dcbc0 100644 --- a/FrontEnd/src/pages/setting/index.tsx +++ b/FrontEnd/src/pages/setting/index.tsx @@ -11,11 +11,11 @@ import classNames from "classnames"; import { useC, Text } from "@/common"; import { useUser, userService } from "@/services/user"; import { getHttpUserClient } from "@/http/user"; -import { TimelineVisibility } from "@/http/timeline"; import ConfirmDialog from "@/views/common/dialog/ConfirmDialog"; import Card from "@/views/common/Card"; import Spinner from "@/views/common/Spinner"; +import Page from "@/views/common/Page"; import ChangePasswordDialog from "./ChangePasswordDialog"; import ChangeAvatarDialog from "./ChangeAvatarDialog"; import ChangeNicknameDialog from "./ChangeNicknameDialog"; @@ -24,9 +24,9 @@ import "./index.css"; import { pushAlert } from "@/services/alert"; interface SettingSectionProps - extends Omit, "title"> { + extends Omit, "title"> { title: Text; - children: ReactNode; + children?: ReactNode; } function SettingSection({ @@ -40,7 +40,7 @@ function SettingSection({ return (

    {c(title)}

    - {children} +
    {children}
    ); } @@ -115,6 +115,7 @@ function SelectSettingsItem({ ) : ( -
    -
    -
    -
    -
    - - ); - } 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 deleted file mode 100644 index 7ba12de8..00000000 --- a/FrontEnd/src/views/settings/ChangeNicknameDialog.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { getHttpUserClient } from "@/http/user"; -import { useUser } from "@/services/user"; -import * as React from "react"; - -import OperationDialog from "../common/dialog/OperationDialog"; - -export interface ChangeNicknameDialogProps { - open: boolean; - close: () => void; -} - -const ChangeNicknameDialog: React.FC = (props) => { - const user = useUser(); - - if (user == null) return null; - - return ( - { - return getHttpUserClient().patch(user.username, { - nickname: newNickname, - }); - }} - onClose={props.close} - /> - ); -}; - -export default ChangeNicknameDialog; diff --git a/FrontEnd/src/views/settings/ChangePasswordDialog.tsx b/FrontEnd/src/views/settings/ChangePasswordDialog.tsx deleted file mode 100644 index a34ca4a7..00000000 --- a/FrontEnd/src/views/settings/ChangePasswordDialog.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { useState } from "react"; -import * as React from "react"; -import { useNavigate } from "react-router-dom"; - -import { userService } from "@/services/user"; - -import OperationDialog from "../common/dialog/OperationDialog"; - -export interface ChangePasswordDialogProps { - open: boolean; - close: () => void; -} - -const ChangePasswordDialog: React.FC = (props) => { - const navigate = useNavigate(); - - 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); - }} - onClose={() => { - props.close(); - if (redirect) { - navigate("/login"); - } - }} - /> - ); -}; - -export default ChangePasswordDialog; diff --git a/FrontEnd/src/views/settings/index.css b/FrontEnd/src/views/settings/index.css deleted file mode 100644 index ccf7a97a..00000000 --- a/FrontEnd/src/views/settings/index.css +++ /dev/null @@ -1,31 +0,0 @@ -.change-avatar-cropper-row { - max-height: 400px; -} - -.change-avatar-img { - min-width: 50%; - max-width: 100%; - max-height: 400px; -} - -.settings-item { - padding: 0.5em 1em; - transition: background 0.3s; - border-bottom: 1px solid #e9ecef; - align-items: center; -} -.settings-item.first { - border-top: 1px solid #e9ecef; -} -.settings-item.clickable { - cursor: pointer; -} -.settings-item:hover { - background: #dee2e6; -} - -.register-code { - border: 1px solid black; - border-radius: 3px; - padding: 0.2em; -} \ No newline at end of file diff --git a/FrontEnd/src/views/settings/index.tsx b/FrontEnd/src/views/settings/index.tsx deleted file mode 100644 index 6647826f..00000000 --- a/FrontEnd/src/views/settings/index.tsx +++ /dev/null @@ -1,338 +0,0 @@ -import { useState } from "react"; -import * as React from "react"; -import { useNavigate } from "react-router-dom"; -import { useTranslation } from "react-i18next"; -import classNames from "classnames"; - -import { convertI18nText, I18nText, UiLogicError } from "@/common"; -import { useUser, userService } from "@/services/user"; -import { getHttpUserClient } from "@/http/user"; -import { TimelineVisibility } from "@/http/timeline"; - -import ConfirmDialog from "../common/dialog/ConfirmDialog"; -import Card from "../common/Card"; -import Spinner from "../common/Spinner"; -import ChangePasswordDialog from "./ChangePasswordDialog"; -import ChangeAvatarDialog from "./ChangeAvatarDialog"; -import ChangeNicknameDialog from "./ChangeNicknameDialog"; - -import "./index.css"; -import { pushAlert } from "@/services/alert"; - -interface SettingSectionProps { - title: I18nText; - children: React.ReactNode; -} - -const SettingSection: React.FC = ({ title, children }) => { - const { t } = useTranslation(); - - return ( - -

    - {convertI18nText(title, t)} -

    - {children} -
    - ); -}; - -interface SettingItemContainerWithoutChildrenProps { - title: I18nText; - subtext?: I18nText; - first?: boolean; - danger?: boolean; - style?: React.CSSProperties; - className?: string; - onClick?: () => void; -} - -interface SettingItemContainerProps - extends SettingItemContainerWithoutChildrenProps { - children?: React.ReactNode; -} - -function SettingItemContainer({ - title, - subtext, - first, - danger, - children, - style, - className, - onClick, -}: SettingItemContainerProps): JSX.Element { - const { t } = useTranslation(); - - return ( -
    -
    -
    - {convertI18nText(title, t)} -
    - - {convertI18nText(subtext, t)} - -
    -
    {children}
    -
    - ); -} - -type ButtonSettingItemProps = SettingItemContainerWithoutChildrenProps; - -const ButtonSettingItem: React.FC = ({ ...props }) => { - return ; -}; - -interface SelectSettingItemProps - extends SettingItemContainerWithoutChildrenProps { - options: { - value: string; - label: I18nText; - }[]; - value?: string; - onSelect: (value: string) => void; -} - -const SelectSettingsItem: React.FC = ({ - options, - value, - onSelect, - ...props -}) => { - const { t } = useTranslation(); - - return ( - - {value == null ? ( - - ) : ( - - )} - - ); -}; - -const SettingsPage: React.FC = () => { - const { i18n } = useTranslation(); - const user = useUser(); - const navigate = useNavigate(); - - const [dialog, setDialog] = useState< - | null - | "changepassword" - | "changeavatar" - | "changenickname" - | "logout" - | "renewregistercode" - >(null); - - const [registerCode, setRegisterCode] = useState( - undefined, - ); - - const [bookmarkVisibility, setBookmarkVisibility] = - useState(); - - React.useEffect(() => { - if (user != null) { - void getHttpUserClient() - .getBookmarkVisibility(user.username) - .then(({ visibility }) => { - setBookmarkVisibility(visibility); - }); - } else { - setBookmarkVisibility(undefined); - } - }, [user]); - - React.useEffect(() => { - setRegisterCode(undefined); - }, [user]); - - React.useEffect(() => { - if (user != null && registerCode === undefined) { - void getHttpUserClient() - .getRegisterCode(user.username) - .then((code) => { - setRegisterCode(code.registerCode ?? null); - }); - } - }, [user, registerCode]); - - const language = i18n.language.slice(0, 2); - - return ( - <> -
    - {user ? ( - - setDialog("renewregistercode")} - > - {registerCode === undefined ? ( - - ) : registerCode === null ? ( - Noop - ) : ( - { - void navigator.clipboard - .writeText(registerCode) - .then(() => { - pushAlert({ - type: "success", - message: "settings.myRegisterCodeCopied", - }); - }); - event.stopPropagation(); - }} - > - {registerCode} - - )} - - setDialog("changeavatar")} - first - /> - setDialog("changenickname")} - /> - { - void getHttpUserClient() - .putBookmarkVisibility(user.username, { - visibility: value as TimelineVisibility, - }) - .then(() => { - setBookmarkVisibility(value as TimelineVisibility); - }); - }} - /> - setDialog("changepassword")} - danger - /> - { - setDialog("logout"); - }} - danger - /> - - ) : null} - - { - void i18n.changeLanguage(value); - }} - first - /> - -
    - setDialog(null)} - /> - setDialog(null)} - open={dialog === "logout"} - onConfirm={() => { - void userService.logout().then(() => { - navigate("/"); - }); - }} - /> - setDialog(null)} - open={dialog === "renewregistercode"} - onConfirm={() => { - if (user == null) throw new UiLogicError(); - void getHttpUserClient() - .renewRegisterCode(user.username) - .then(() => { - setRegisterCode(undefined); - }); - }} - /> - setDialog(null)} - /> - setDialog(null)} - /> - - ); -}; - -export default SettingsPage; -- cgit v1.2.3 From 4c4bb2d0a25702b840ef56e1e236498376f66bee Mon Sep 17 00:00:00 2001 From: crupest Date: Fri, 21 Jul 2023 17:07:28 +0800 Subject: ... --- FrontEnd/src/pages/setting/index.css | 23 ++++++++++++++++++----- FrontEnd/src/pages/setting/index.tsx | 1 + 2 files changed, 19 insertions(+), 5 deletions(-) (limited to 'FrontEnd/src/pages/setting') diff --git a/FrontEnd/src/pages/setting/index.css b/FrontEnd/src/pages/setting/index.css index af5ccf20..9458d937 100644 --- a/FrontEnd/src/pages/setting/index.css +++ b/FrontEnd/src/pages/setting/index.css @@ -43,6 +43,10 @@ color: var(--cru-secondary-text-color); } +.setting-item-value-area { + margin-left: auto; +} + .setting-item-container.setting-type-button { cursor: pointer; } @@ -51,6 +55,13 @@ color: var(--cru-danger-color); } +.register-code { + background: var(--cru-surface-container-highest-color); + border: 1px solid var(--cru-surface-container-highest-color); + border-radius: 3px; + padding: 0.2em; + cursor: pointer; +} @media (max-width: 576) { .setting-item-container.setting-type-select { @@ -60,10 +71,12 @@ .setting-item-container.setting-type-select .setting-item-value-area { margin-top: 1em; } -} -.register-code { - border: 1px solid black; - border-radius: 3px; - padding: 0.2em; + .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 index 5d6dcbc0..2599f2f5 100644 --- a/FrontEnd/src/pages/setting/index.tsx +++ b/FrontEnd/src/pages/setting/index.tsx @@ -158,6 +158,7 @@ function RegisterCodeSettingItem() { setDialogOpen(true)} > {registerCode === undefined ? ( -- cgit v1.2.3 From ae1b296b5e967d1e329f5a1e6165ca0f05dce0cb Mon Sep 17 00:00:00 2001 From: crupest Date: Fri, 21 Jul 2023 17:25:01 +0800 Subject: ... --- FrontEnd/src/pages/setting/index.css | 1 - FrontEnd/src/views/common/Card.css | 4 ---- FrontEnd/src/views/common/dialog/Dialog.css | 2 +- FrontEnd/src/views/common/dialog/Dialog.tsx | 20 +++++++++++++++----- 4 files changed, 16 insertions(+), 11 deletions(-) (limited to 'FrontEnd/src/pages/setting') diff --git a/FrontEnd/src/pages/setting/index.css b/FrontEnd/src/pages/setting/index.css index 9458d937..d9cba24f 100644 --- a/FrontEnd/src/pages/setting/index.css +++ b/FrontEnd/src/pages/setting/index.css @@ -36,7 +36,6 @@ .setting-item-container:hover { background-color: var(--cru-key-container-1-color); - border-bottom-color: var(--cru-key-container-1-color); } .setting-item-label-sub { diff --git a/FrontEnd/src/views/common/Card.css b/FrontEnd/src/views/common/Card.css index 5b3dbbe9..f4cdc31c 100644 --- a/FrontEnd/src/views/common/Card.css +++ b/FrontEnd/src/views/common/Card.css @@ -5,7 +5,3 @@ border-color: var(--cru-key-container-color); transition: all 0.3s; } - -.cru-card:hover { - border-color: var(--cru-key-1-color); -} \ No newline at end of file diff --git a/FrontEnd/src/views/common/dialog/Dialog.css b/FrontEnd/src/views/common/dialog/Dialog.css index 108bd823..99e1a516 100644 --- a/FrontEnd/src/views/common/dialog/Dialog.css +++ b/FrontEnd/src/views/common/dialog/Dialog.css @@ -27,7 +27,7 @@ margin: auto; - border: var(--cru-primary-color) 1px solid; + border: var(--cru-key-container-color) 1px solid; border-radius: 5px; padding: 1.5em; background-color: var(--cru-surface-color); diff --git a/FrontEnd/src/views/common/dialog/Dialog.tsx b/FrontEnd/src/views/common/dialog/Dialog.tsx index 79a31954..31dd113b 100644 --- a/FrontEnd/src/views/common/dialog/Dialog.tsx +++ b/FrontEnd/src/views/common/dialog/Dialog.tsx @@ -1,6 +1,9 @@ import { ReactNode } from "react"; import ReactDOM from "react-dom"; import { CSSTransition } from "react-transition-group"; +import classNames from "classnames"; + +import { ThemeColor } from "../common"; import "./Dialog.css"; @@ -11,14 +14,21 @@ if (optionalPortalElement == null) { const portalElement = optionalPortalElement; interface DialogProps { - onClose: () => void; open: boolean; + onClose: () => void; + color?: ThemeColor; children?: ReactNode; disableCloseOnClickOnOverlay?: boolean; } -export default function Dialog(props: DialogProps) { - const { open, onClose, children, disableCloseOnClickOnOverlay } = props; +export default function Dialog({ + open, + onClose, + color, + children, + disableCloseOnClickOnOverlay, +}: DialogProps) { + color = color ?? "primary"; return ReactDOM.createPortal(
    -
    +
    e.stopPropagation()} -- cgit v1.2.3 From 4f8d933994c576dc180fae23a3dca477d2354939 Mon Sep 17 00:00:00 2001 From: crupest Date: Mon, 24 Jul 2023 21:48:48 +0800 Subject: ... --- .../src/pages/setting/ChangePasswordDialog.tsx | 11 +- .../src/views/common/dialog/OperationDialog.css | 17 +- .../src/views/common/dialog/OperationDialog.tsx | 409 ++++----------------- FrontEnd/src/views/common/input/InputGroup.css | 25 ++ FrontEnd/src/views/common/input/InputGroup.tsx | 362 ++++++++++++++++++ FrontEnd/src/views/common/input/InputPanel.css | 25 -- FrontEnd/src/views/common/input/InputPanel.tsx | 246 ------------- FrontEnd/src/views/register/index.tsx | 9 +- 8 files changed, 483 insertions(+), 621 deletions(-) create mode 100644 FrontEnd/src/views/common/input/InputGroup.css create mode 100644 FrontEnd/src/views/common/input/InputGroup.tsx delete mode 100644 FrontEnd/src/views/common/input/InputPanel.css delete mode 100644 FrontEnd/src/views/common/input/InputPanel.tsx (limited to 'FrontEnd/src/pages/setting') diff --git a/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx b/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx index a523b454..5505137e 100644 --- a/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx +++ b/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx @@ -1,24 +1,25 @@ import { useState } from "react"; -import * as React from "react"; import { useNavigate } from "react-router-dom"; import { userService } from "@/services/user"; import OperationDialog from "@/views/common/dialog/OperationDialog"; -export interface ChangePasswordDialogProps { +interface ChangePasswordDialogProps { open: boolean; close: () => void; } -const ChangePasswordDialog: React.FC = (props) => { +export function ChangePasswordDialog(props: ChangePasswordDialogProps) { + const { open, close } = props; + const navigate = useNavigate(); const [redirect, setRedirect] = useState(false); return ( = (props) => { }} /> ); -}; +} export default ChangePasswordDialog; diff --git a/FrontEnd/src/views/common/dialog/OperationDialog.css b/FrontEnd/src/views/common/dialog/OperationDialog.css index 2f7617d0..19c5d806 100644 --- a/FrontEnd/src/views/common/dialog/OperationDialog.css +++ b/FrontEnd/src/views/common/dialog/OperationDialog.css @@ -1,3 +1,18 @@ +.cru-operation-dialog-title { + font-size: 1.2em; + font-weight: bold; + color: var(--cru-key-color); + margin-bottom: 0.5em; +} + +.cru-operation-dialog-prompt { + color: var(--cru-surface-on-color); +} + +.cru-operation-dialog-main-area { + margin-top: 0.5em; +} + .cru-operation-dialog-group { display: block; margin: 0.4em 0; @@ -22,4 +37,4 @@ display: block; font-size: 0.8em; color: var(--cru-primary-color); -} +} \ No newline at end of file diff --git a/FrontEnd/src/views/common/dialog/OperationDialog.tsx b/FrontEnd/src/views/common/dialog/OperationDialog.tsx index ad00c424..ad9bf5c1 100644 --- a/FrontEnd/src/views/common/dialog/OperationDialog.tsx +++ b/FrontEnd/src/views/common/dialog/OperationDialog.tsx @@ -1,180 +1,76 @@ -import { useState, ReactNode, ComponentPropsWithoutRef } from "react"; +import { useState, ReactNode } from "react"; import classNames from "classnames"; -import moment from "moment"; import { useC, Text, ThemeColor } from "../common"; import Button from "../button/Button"; +import { + default as InputGroup, + InputErrors, + InputList, + Validator, + Values, + useDirties, +} from "../input/InputGroup"; import LoadingButton from "../button/LoadingButton"; import Dialog from "./Dialog"; import "./OperationDialog.css"; -interface DefaultPromptProps { - color?: ThemeColor; +interface OperationDialogPromptProps { message?: Text; customMessage?: ReactNode; className?: string; } -function DefaultPrompt(props: DefaultPromptProps) { - const { color, message, customMessage, className } = props; +function OperationDialogPrompt(props: OperationDialogPromptProps) { + const { message, customMessage, className } = props; const c = useC(); return ( -
    -

    {c(message)}

    +
    + {message &&

    {c(message)}

    } {customMessage}
    ); } -export interface OperationDialogTextInput { - type: "text"; - label?: Text; - password?: boolean; - initValue?: string; - textFieldProps?: Omit< - ComponentPropsWithoutRef<"input">, - "type" | "value" | "onChange" - >; - helperText?: Text; -} - -export interface OperationDialogBoolInput { - type: "bool"; - label: Text; - initValue?: boolean; - helperText?: Text; -} - -export interface OperationDialogSelectInputOption { - value: string; - label: Text; - icon?: ReactNode; -} - -export interface OperationDialogSelectInput { - type: "select"; - label: Text; - options: OperationDialogSelectInputOption[]; - initValue?: string; -} - -export interface OperationDialogDateTimeInput { - type: "datetime"; - label?: Text; - initValue?: string; - helperText?: string; -} - -export type OperationDialogInput = - | OperationDialogTextInput - | OperationDialogBoolInput - | OperationDialogSelectInput - | OperationDialogDateTimeInput; - -interface OperationInputTypeStringToValueTypeMap { - text: string; - bool: boolean; - select: string; - datetime: string; -} - -type OperationInputValueType = - OperationInputTypeStringToValueTypeMap[keyof OperationInputTypeStringToValueTypeMap]; - -type MapOperationInputTypeStringToValueType = - Type extends keyof OperationInputTypeStringToValueTypeMap - ? OperationInputTypeStringToValueTypeMap[Type] - : never; - -type MapOperationInputInfoValueType = T extends OperationDialogInput - ? MapOperationInputTypeStringToValueType - : T; - -type MapOperationInputInfoValueTypeList< - Tuple extends readonly OperationDialogInput[], -> = { - [Index in keyof Tuple]: MapOperationInputInfoValueType; -}; - -export type OperationInputError = - | { - [index: number]: Text | null | undefined; - } - | null - | undefined; - -const isNoError = (error: OperationInputError): boolean => { - if (error == null) return true; - for (const key in error) { - if (error[key] != null) return false; - } - return true; -}; - -type ItemValueMapper = { - [T in OperationDialogInput as T["type"]]: ( - item: T, - ) => MapOperationInputInfoValueType; -}; - -type ValueValueMapper = { - [T in OperationDialogInput as T["type"]]: ( - item: MapOperationInputInfoValueType, - ) => MapOperationInputInfoValueType; -}; - -const initValueMapperMap: ItemValueMapper = { - bool: (item) => item.initValue ?? false, - datetime: (item) => - item.initValue != null - ? /* cspell: disable-next-line */ - moment(item.initValue).format("YYYY-MM-DDTHH:mm:ss") - : "", - select: (item) => item.initValue ?? item.options[0].value, - text: (item) => item.initValue ?? "", -}; - -const finalValueMapperMap: ValueValueMapper = { - bool: (value) => value, - datetime: (value) => new Date(value).toISOString(), - select: (value) => value, - text: (value) => value, -}; - -export interface OperationDialogProps< - TData, - OperationInputInfoList extends readonly OperationDialogInput[], -> { +export interface OperationDialogProps { open: boolean; onClose: () => void; - themeColor?: ThemeColor; + color?: ThemeColor; title: Text; inputPrompt?: Text; processPrompt?: Text; successPrompt?: (data: TData) => ReactNode; failurePrompt?: (error: unknown) => ReactNode; - inputScheme?: OperationInputInfoList; - inputValidator?: ( - inputs: MapOperationInputInfoValueTypeList, - ) => OperationInputError; + inputs: Inputs; + validator?: Validator; - onProcess: ( - inputs: MapOperationInputInfoValueTypeList, - ) => Promise; + onProcess: (inputs: Values) => Promise; onSuccessAndClose?: (data: TData) => void; } -function OperationDialog< - TData, - OperationInputInfoList extends readonly OperationDialogInput[], ->(props: OperationDialogProps) { - const inputScheme = props.inputScheme ?? ([] as const); +function OperationDialog( + props: OperationDialogProps, +) { + const { + open, + onClose, + color, + title, + inputPrompt, + processPrompt, + successPrompt, + failurePrompt, + inputs, + validator, + onProcess, + onSuccessAndClose, + } = props; const c = useC(); @@ -191,21 +87,9 @@ function OperationDialog< }; const [step, setStep] = useState({ type: "input" }); - - type Values = MapOperationInputInfoValueTypeList; - - const [values, setValues] = useState( - () => - inputScheme.map((item) => - initValueMapperMap[item.type](item as never), - ) as Values, - ); - - const [dirtyList, setDirtyList] = useState(() => - inputScheme.map(() => false), - ); - - const [inputError, setInputError] = useState(); + const [values, setValues] = useState>(); + const [errors, setErrors] = useState(); + const [dirties, setDirties, dirtyAll] = useDirties(); function close() { if (step.type !== "process") { @@ -244,178 +128,28 @@ function OperationDialog< let body: ReactNode; if (step.type === "input" || step.type === "process") { - const process = step.type === "process"; - - const validate = (values: Values): boolean => { - const { inputValidator } = props; - if (inputValidator != null) { - const result = inputValidator(values); - setInputError(result); - return isNoError(result); - } - return true; - }; - - const updateValue = ( - index: number, - newValue: OperationInputValueType, - ): void => { - const oldValues = values; - const newValues = oldValues.slice(); - newValues[index] = newValue; - setValues(newValues as Values); - if (dirtyList[index] === false) { - const newDirtyList = dirtyList.slice(); - newDirtyList[index] = true; - setDirtyList(newDirtyList); - } - validate(newValues as Values); - }; - - const canProcess = isNoError(inputError); + const isProcessing = step.type === "process"; + const hasError = errors.length > 0; body = (
    -
    {c(props.inputPrompt)}
    - {inputScheme.map((item: OperationDialogInput, index: number) => { - const value = values[index]; - const error: string | null = - dirtyList[index] && inputError != null - ? c(inputError[index]) - : null; - - if (item.type === "text") { - return ( -
    - {item.label && ( - - )} - { - const v = event.target.value; - updateValue(index, v); - }} - disabled={process} - /> - {error && ( -
    - {error} -
    - )} - {item.helperText && ( -
    - {c(item.helperText)} -
    - )} -
    - ); - } else if (item.type === "bool") { - return ( -
    - { - const v = event.currentTarget.checked; - updateValue(index, v); - }} - disabled={process} - /> - - {error && ( -
    - {error} -
    - )} - {item.helperText && ( -
    - {c(item.helperText)} -
    - )} -
    - ); - } else if (item.type === "select") { - return ( -
    - - -
    - ); - } else if (item.type === "datetime") { - return ( -
    - {item.label && ( - - )} - { - const v = event.target.value; - updateValue(index, v); - }} - disabled={process} - /> - {error && ( -
    - {error} -
    - )} -
    - ); - } - })} + + { + setValues(values); + setErrors(errors); + }} + dirties={dirties} + onDirty={setDirties} + />

    @@ -424,14 +158,14 @@ function OperationDialog< color="secondary" outline onClick={close} - disabled={process} + disabled={isProcessing} /> { - setDirtyList(inputScheme.map(() => true)); + dirtyAll(); if (validate(values)) { onConfirm(); } @@ -445,21 +179,19 @@ function OperationDialog< } else { const result = step; - const promptProps: DefaultPromptProps = + const promptProps: OperationDialogPromptProps = result.type === "success" ? { - color: "success", message: "operationDialog.success", customMessage: props.successPrompt?.(result.data), } : { - color: "danger", message: "operationDialog.error", customMessage: props.failurePrompt?.(result.data), }; body = (
    - +
    ); } return ( - +
    -
    {c(props.title)}
    +
    {c(title)}

    {body}
    diff --git a/FrontEnd/src/views/common/input/InputGroup.tsx b/FrontEnd/src/views/common/input/InputGroup.tsx index 7c33def7..232edfc9 100644 --- a/FrontEnd/src/views/common/input/InputGroup.tsx +++ b/FrontEnd/src/views/common/input/InputGroup.tsx @@ -23,7 +23,7 @@ * `useInputs` hook takes care of logic and generate props for `InputGroup`. */ -import { useState, useRef, Ref } from "react"; +import { useState, Ref } from "react"; import classNames from "classnames"; import { useC, Text, ThemeColor } from "../common"; @@ -86,7 +86,7 @@ export type InputScheme = { validator?: Validator; }; -export type InputState = { +export type InputData = { values: InputValueDict; errors: InputErrorDict; disabled: InputDisabledDict; @@ -95,16 +95,18 @@ export type InputState = { export type State = { scheme: InputScheme; - state: InputState; + data: InputData; }; -export type StateInitializer = Partial; +export type DataInitializeInfo = Partial; -export type Initializer = { +export type InitializeInfo = { scheme: InputScheme; - stateInit?: Partial; + dataInit?: DataInitializeInfo; }; +export type Initialize + export interface InputGroupProps { color?: ThemeColor; containerClassName?: string; @@ -114,7 +116,7 @@ export interface InputGroupProps { onChange: (index: number, value: Input["value"]) => void; } -function cleanObject>(o: O): O { +function cleanObject(o: Record): Record { const result = { ...o }; for (const key of Object.keys(result)) { if (result[key] == null) { @@ -124,8 +126,23 @@ function cleanObject>(o: O): O { return result; } -export function useInputs(options: { init?: () => Initializer }): { +export type ConfirmResult = + | { + type: "ok"; + values: InputValueDict; + } + | { + type: "error"; + errors: InputErrorDict; + }; + +export function useInputs(options: { + init: InitializeInfo | (() => InitializeInfo); +}): { inputGroupProps: InputGroupProps; + hasError: boolean; + confirm: () => ConfirmResult; + setAllDisabled: (disabled: boolean) => void; } { function initializeValue( input: InputInfo, @@ -141,54 +158,59 @@ export function useInputs(options: { init?: () => Initializer }): { throw new Error("Unknown input type"); } - function initialize(initializer: Initializer): State { - const { scheme, stateInit } = initializer; + function initialize(info: InitializeInfo): State { + const { scheme, dataInit } = info; const { inputs, validator } = scheme; const keys = inputs.map((input) => input.key); if (process.env.NODE_ENV === "development") { - const checkKeys = (dict: Record) => { - for (const key of Object.keys(dict)) { - if (!keys.includes(key)) { - console.warn(""); + const checkKeys = (dict: Record | undefined) => { + if (dict != null) { + for (const key of Object.keys(dict)) { + if (!keys.includes(key)) { + console.warn(""); + } } } }; - checkKeys(stateInit?.values ?? {}); - checkKeys(stateInit?.errors ?? {}); - checkKeys(stateInit?.disabled ?? {}); - checkKeys(stateInit?.dirties ?? {}); + checkKeys(dataInit?.values); + checkKeys(dataInit?.errors); + checkKeys(dataInit?.disabled); + checkKeys(dataInit?.dirties); + } + + function clean(dict: Record | undefined): Record { + return dict != null ? cleanObject(dict) : {}; } const values: InputValueDict = {}; - let errors: InputErrorDict = cleanObject( - initializer.stateInit?.errors ?? {}, - ); - const disabled: InputDisabledDict = cleanObject( - initializer.stateInit?.disabled ?? {}, - ); - const dirties: InputDirtyDict = cleanObject( - initializer.stateInit?.dirties ?? {}, - ); + const disabled: InputDisabledDict = clean(info.dataInit?.disabled); + const dirties: InputDirtyDict = clean(info.dataInit?.dirties); for (let i = 0; i < inputs.length; i++) { const input = inputs[i]; const { key } = input; - values[key] = initializeValue(input, stateInit?.values?.[key]); - if (!(key in dirties)) { - dirties[key] = false; - } + values[key] = initializeValue(input, dataInit?.values?.[key]); } - if (Object.keys(errors).length === 0 && validator != null) { - errors = validator(values, inputs); + let errors = info.dataInit?.errors; + + if (errors != null) { + if (process.env.NODE_ENV === "development") { + console.log( + "You explicitly set errors (not undefined) in initializer, so validator won't run.", + ); + } + errors = cleanObject(errors); + } else { + errors = validator?.(values, inputs) ?? {}; } return { scheme, - state: { + data: { values, errors, disabled, @@ -198,31 +220,95 @@ export function useInputs(options: { init?: () => Initializer }): { } const { init } = options; + const initializer = typeof init === "function" ? init : () => init; + + const [state, setState] = useState(() => initialize(initializer())); + + const { scheme, data } = state; + const { validator } = scheme; + + function createAllBooleanDict(value: boolean): Record { + const result: InputDirtyDict = {}; + for (const key of scheme.inputs.map((input) => input.key)) { + result[key] = value; + } + return result; + } + + const createAllDirties = () => createAllBooleanDict(true); const componentInputs: Input[] = []; - for (let i = 0; i < inputs.length; i++) { - const input = { ...inputs[i] }; - const error = dirties[i] - ? errors.find((e) => e.index === i)?.message - : undefined; - const componentInput: ExtendInputForComponent = { + for (let i = 0; i < scheme.inputs.length; i++) { + const input = scheme.inputs[i]; + const value = data.values[input.key]; + const error = data.errors[input.key]; + const disabled = data.disabled[input.key] ?? false; + const dirty = data.dirties[input.key] ?? false; + const componentInput: Input = { ...input, - value: values[i], + value: value as never, disabled, - error, + error: dirty ? error : undefined, }; componentInputs.push(componentInput); } - const dirtyAll = () => { - if (dirties != null) { - setDirties(new Array(dirties.length).fill(true) as Dirties); - } - }; - return { - inputGroupProps: {}, + inputGroupProps: { + inputs: componentInputs, + onChange: (index, value) => { + const input = scheme.inputs[index]; + const { key } = input; + const newValues = { ...data.values, [key]: value }; + const newDirties = { ...data.dirties, [key]: true }; + const newErrors = validator?.(newValues, scheme.inputs) ?? {}; + setState({ + scheme, + data: { + ...data, + values: newValues, + errors: newErrors, + dirties: newDirties, + }, + }); + }, + }, + hasError: Object.keys(data.errors).length > 0, + confirm() { + const newDirties = createAllDirties(); + const newErrors = validator?.(data.values, scheme.inputs) ?? {}; + + setState({ + scheme, + data: { + ...data, + dirties: newDirties, + errors: newErrors, + }, + }); + + if (Object.keys(newErrors).length === 0) { + return { + type: "error", + errors: newErrors, + }; + } else { + return { + type: "ok", + values: data.values, + }; + } + }, + setAllDisabled(disabled: boolean) { + setState({ + scheme, + data: { + ...data, + disabled: createAllBooleanDict(disabled), + }, + }); + }, }; } diff --git a/FrontEnd/src/views/timeline/PostPropertyChangeDialog.tsx b/FrontEnd/src/views/timeline/PostPropertyChangeDialog.tsx index fc55185c..76f542c1 100644 --- a/FrontEnd/src/views/timeline/PostPropertyChangeDialog.tsx +++ b/FrontEnd/src/views/timeline/PostPropertyChangeDialog.tsx @@ -15,7 +15,7 @@ function PostPropertyChangeDialog(props: { return ( = (props) => { return ( { diff --git a/FrontEnd/src/views/timeline/TimelinePropertyChangeDialog.tsx b/FrontEnd/src/views/timeline/TimelinePropertyChangeDialog.tsx index bd5bef4c..a0eebdbb 100644 --- a/FrontEnd/src/views/timeline/TimelinePropertyChangeDialog.tsx +++ b/FrontEnd/src/views/timeline/TimelinePropertyChangeDialog.tsx @@ -55,7 +55,7 @@ const TimelinePropertyChangeDialog: React.FC< ] as const } open={props.open} - onClose={props.close} + close={props.close} onProcess={([newTitle, newVisibility, newDescription, newColor]) => { const req: HttpTimelinePatchRequest = {}; if (newTitle !== timeline.title) { -- cgit v1.2.3 From 2d4a75a21a8a97db8017b56e321c56c7d70bc674 Mon Sep 17 00:00:00 2001 From: crupest Date: Sat, 29 Jul 2023 01:22:38 +0800 Subject: ... --- FrontEnd/src/index.css | 16 ----- .../src/pages/setting/ChangeNicknameDialog.tsx | 29 ++++---- .../src/pages/setting/ChangePasswordDialog.tsx | 79 +++++++++++++--------- FrontEnd/src/views/common/button/Button.css | 1 + FrontEnd/src/views/common/dialog/Dialog.css | 11 ++- FrontEnd/src/views/common/dialog/Dialog.tsx | 27 +++----- .../src/views/common/dialog/OperationDialog.css | 29 ++------ .../src/views/common/dialog/OperationDialog.tsx | 32 ++++----- FrontEnd/src/views/common/input/InputGroup.css | 43 ++++++++++-- FrontEnd/src/views/common/input/InputGroup.tsx | 44 ++++++------ FrontEnd/src/views/common/theme.css | 2 + 11 files changed, 162 insertions(+), 151 deletions(-) (limited to 'FrontEnd/src/pages/setting') diff --git a/FrontEnd/src/index.css b/FrontEnd/src/index.css index 49791c23..ee92520b 100644 --- a/FrontEnd/src/index.css +++ b/FrontEnd/src/index.css @@ -30,22 +30,6 @@ textarea:focus { border-color: var(--cru-primary-color); } -input:not([type="checkbox"]):not([type="radio"]) { - resize: none; - outline: none; - border: 1px solid; - transition: all 0.5s; - border-color: var(--cru-background-2-color); -} - -input:hover:not([type="checkbox"]):not([type="radio"]) { - border-color: var(--cru-primary-r2-color); -} - -input:focus:not([type="checkbox"]):not([type="radio"]) { - border-color: var(--cru-primary-color); -} - .white-space-no-wrap { white-space: nowrap; } diff --git a/FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx b/FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx index 58bbac5f..5606ce94 100644 --- a/FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx +++ b/FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx @@ -1,6 +1,5 @@ import { getHttpUserClient } from "@/http/user"; -import { useUser } from "@/services/user"; -import * as React from "react"; +import { useUserLoggedIn } from "@/services/user"; import OperationDialog from "@/views/common/dialog/OperationDialog"; @@ -9,26 +8,28 @@ export interface ChangeNicknameDialogProps { close: () => void; } -const ChangeNicknameDialog: React.FC = (props) => { - const user = useUser(); +export default function ChangeNicknameDialog(props: ChangeNicknameDialogProps) { + const { open, close } = props; - if (user == null) return null; + const user = useUserLoggedIn(); return ( { + onProcess={({ newNickname }) => { return getHttpUserClient().patch(user.username, { - nickname: newNickname, + nickname: newNickname as string, }); }} - close={props.close} + close={close} /> ); -}; - -export default ChangeNicknameDialog; +} diff --git a/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx b/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx index 9ca95168..407f3051 100644 --- a/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx +++ b/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx @@ -3,7 +3,9 @@ import { useNavigate } from "react-router-dom"; import { userService } from "@/services/user"; -import OperationDialog from "@/views/common/dialog/OperationDialog"; +import OperationDialog, { + InputErrorDict, +} from "@/views/common/dialog/OperationDialog"; interface ChangePasswordDialogProps { open: boolean; @@ -20,45 +22,56 @@ export function ChangePasswordDialog(props: ChangePasswordDialogProps) { return ( { + const result: InputErrorDict = {}; + if (oldPassword === "") { + result["oldPassword"] = + "settings.dialogChangePassword.errorEmptyOldPassword"; + } + if (newPassword === "") { + result["newPassword"] = + "settings.dialogChangePassword.errorEmptyNewPassword"; + } + if (retypedNewPassword !== newPassword) { + result["retypedNewPassword"] = + "settings.dialogChangePassword.errorRetypeNotMatch"; + } + return result; }, - { - type: "text", - label: "settings.dialogChangePassword.inputNewPassword", - password: true, - }, - { - type: "text", - label: "settings.dialogChangePassword.inputRetypeNewPassword", - password: true, - }, - ]} - inputValidator={([oldPassword, newPassword, retypedNewPassword]) => { - 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); + onProcess={async ({ oldPassword, newPassword }) => { + await userService.changePassword( + oldPassword as string, + newPassword as string, + ); setRedirect(true); }} - close={() => { - props.close(); + onSuccessAndClose={() => { if (redirect) { navigate("/login"); } diff --git a/FrontEnd/src/views/common/button/Button.css b/FrontEnd/src/views/common/button/Button.css index 12c6903e..fe619f9d 100644 --- a/FrontEnd/src/views/common/button/Button.css +++ b/FrontEnd/src/views/common/button/Button.css @@ -5,6 +5,7 @@ border-radius: 0.2em; border: 1px solid; cursor: pointer; + background-color: var(--cru-surface-color); } .cru-button:not(.outline) { diff --git a/FrontEnd/src/views/common/dialog/Dialog.css b/FrontEnd/src/views/common/dialog/Dialog.css index 99e1a516..8f12614b 100644 --- a/FrontEnd/src/views/common/dialog/Dialog.css +++ b/FrontEnd/src/views/common/dialog/Dialog.css @@ -6,7 +6,6 @@ right: 0; bottom: 0; display: flex; - padding: 2em; overflow: auto; } @@ -14,8 +13,8 @@ position: absolute; z-index: -1; left: 0; - top: 0; right: 0; + top: 0; bottom: 0; background-color: var(--cru-surface-dim-color); opacity: 0.8; @@ -25,7 +24,7 @@ max-width: 100%; min-width: 30vw; - margin: auto; + margin: 2em auto; border: var(--cru-key-container-color) 1px solid; border-radius: 5px; @@ -33,6 +32,12 @@ background-color: var(--cru-surface-color); } +@media (min-width: 576px) { + .cru-dialog-container { + max-width: 800px; + } +} + .cru-dialog-bottom-area { display: flex; justify-content: flex-end; diff --git a/FrontEnd/src/views/common/dialog/Dialog.tsx b/FrontEnd/src/views/common/dialog/Dialog.tsx index 31dd113b..9ce344dc 100644 --- a/FrontEnd/src/views/common/dialog/Dialog.tsx +++ b/FrontEnd/src/views/common/dialog/Dialog.tsx @@ -38,23 +38,18 @@ export default function Dialog({ timeout={300} classNames="cru-dialog" > -
    { - onClose(); - } - } - > -
    +
    e.stopPropagation()} - > - {children} -
    + className="cru-dialog-background" + onClick={ + disableCloseOnClickOnOverlay + ? undefined + : () => { + onClose(); + } + } + /> +
    {children}
    , portalElement, diff --git a/FrontEnd/src/views/common/dialog/OperationDialog.css b/FrontEnd/src/views/common/dialog/OperationDialog.css index 19c5d806..43cdb692 100644 --- a/FrontEnd/src/views/common/dialog/OperationDialog.css +++ b/FrontEnd/src/views/common/dialog/OperationDialog.css @@ -9,32 +9,15 @@ color: var(--cru-surface-on-color); } -.cru-operation-dialog-main-area { - margin-top: 0.5em; -} - -.cru-operation-dialog-group { - display: block; - margin: 0.4em 0; -} - -.cru-operation-dialog-label { - display: block; - color: var(--cru-primary-color); +.cru-dialog-middle-area { + margin: 0.5em 0; } -.cru-operation-dialog-inline-label { - margin-inline-start: 0.5em; +.cru-dialog-bottom-area { + margin-top: 0.5em; } -.cru-operation-dialog-error-text { +.cru-operation-dialog-input-group { display: block; - font-size: 0.8em; - color: var(--cru-danger-color); + margin: 0.5em 0; } - -.cru-operation-dialog-helper-text { - display: block; - font-size: 0.8em; - color: var(--cru-primary-color); -} \ No newline at end of file diff --git a/FrontEnd/src/views/common/dialog/OperationDialog.tsx b/FrontEnd/src/views/common/dialog/OperationDialog.tsx index 97d135e9..8aab45d9 100644 --- a/FrontEnd/src/views/common/dialog/OperationDialog.tsx +++ b/FrontEnd/src/views/common/dialog/OperationDialog.tsx @@ -7,15 +7,17 @@ import Button from "../button/Button"; import { useInputs, InputGroup, - InitializeInfo as InputInitializer, + Initializer as InputInitializer, InputValueDict, - InputScheme, + InputErrorDict, } from "../input/InputGroup"; import LoadingButton from "../button/LoadingButton"; import Dialog from "./Dialog"; import "./OperationDialog.css"; +export type { InputInitializer, InputValueDict, InputErrorDict }; + interface OperationDialogPromptProps { message?: Text; customMessage?: ReactNode; @@ -40,13 +42,13 @@ export interface OperationDialogProps { close: () => void; color?: ThemeColor; + inputColor?: ThemeColor; title: Text; inputPrompt?: Text; successPrompt?: (data: TData) => ReactNode; failurePrompt?: (error: unknown) => ReactNode; - inputInit?: InputInitializer; - inputScheme?: InputScheme; + inputs: InputInitializer; onProcess: (inputs: InputValueDict) => Promise; onSuccessAndClose?: (data: TData) => void; @@ -57,25 +59,16 @@ function OperationDialog(props: OperationDialogProps) { open, close, color, + inputColor, title, inputPrompt, successPrompt, failurePrompt, - inputInit, - inputScheme, + inputs, onProcess, onSuccessAndClose, } = props; - if (process.env.NODE_ENV === "development") { - if (inputScheme == null && inputInit == null) { - throw Error("Scheme or Init? Choose one and create one."); - } - if (inputScheme != null && inputInit != null) { - throw Error("Scheme or Init? Choose one and drop one"); - } - } - const c = useC(); type Step = @@ -93,14 +86,13 @@ function OperationDialog(props: OperationDialogProps) { const [step, setStep] = useState({ type: "input" }); const { inputGroupProps, hasError, setAllDisabled, confirm } = useInputs({ - /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */ - init: inputInit ?? { scheme: inputScheme! }, + init: inputs, }); function onClose() { if (step.type !== "process") { close(); - if (step.type === "success" && props.onSuccessAndClose) { + if (step.type === "success" && onSuccessAndClose) { onSuccessAndClose?.(step.data); } } else { @@ -136,11 +128,11 @@ function OperationDialog(props: OperationDialogProps) { body = (
    -
    +
    diff --git a/FrontEnd/src/views/common/input/InputGroup.css b/FrontEnd/src/views/common/input/InputGroup.css index f9d6ac8b..1763ea53 100644 --- a/FrontEnd/src/views/common/input/InputGroup.css +++ b/FrontEnd/src/views/common/input/InputGroup.css @@ -1,25 +1,54 @@ -.cru-input-panel-group { +.cru-input-group { display: block; +} + +.cru-input-container { margin: 0.4em 0; } -.cru-input-panel-label { +.cru-input-label { display: block; - color: var(--cru-primary-color); + color: var(--cru-key-color); + font-size: 0.9em; + margin-bottom: 0.3em; } -.cru-input-panel-inline-label { +.cru-input-label-inline { margin-inline-start: 0.5em; } -.cru-input-panel-error-text { +.cru-input-type-text input { + appearance: none; + display: block; + border: 1px solid var(--cru-surface-outline-color); + color: var(--cru-surface-on-color); + background-color: var(--cru-surface-color); + margin: 0; + padding: 0; + font-size: 1.2em; +} + +.cru-input-type-text input:hover { + border-color: var(--cru-key-color); +} + +.cru-input-type-text input:focus { + border-color: var(--cru-key-color); +} + +.cru-input-type-text input:disabled { + border-color: var(--cru-surface-on-color); +} + +.cru-input-error { display: block; font-size: 0.8em; color: var(--cru-danger-color); + margin-top: 0.4em; } -.cru-input-panel-helper-text { +.cru-input-helper { display: block; font-size: 0.8em; color: var(--cru-primary-color); -} +} \ No newline at end of file diff --git a/FrontEnd/src/views/common/input/InputGroup.tsx b/FrontEnd/src/views/common/input/InputGroup.tsx index 232edfc9..eed8266b 100644 --- a/FrontEnd/src/views/common/input/InputGroup.tsx +++ b/FrontEnd/src/views/common/input/InputGroup.tsx @@ -98,14 +98,16 @@ export type State = { data: InputData; }; -export type DataInitializeInfo = Partial; +export type DataInitialization = Partial; -export type InitializeInfo = { +export type Initialization = { scheme: InputScheme; - dataInit?: DataInitializeInfo; + dataInit?: DataInitialization; }; -export type Initialize +export type GeneralInitialization = Initialization | InputScheme | InputInfo[]; + +export type Initializer = GeneralInitialization | (() => GeneralInitialization); export interface InputGroupProps { color?: ThemeColor; @@ -136,9 +138,7 @@ export type ConfirmResult = errors: InputErrorDict; }; -export function useInputs(options: { - init: InitializeInfo | (() => InitializeInfo); -}): { +export function useInputs(options: { init: Initializer }): { inputGroupProps: InputGroupProps; hasError: boolean; confirm: () => ConfirmResult; @@ -158,8 +158,14 @@ export function useInputs(options: { throw new Error("Unknown input type"); } - function initialize(info: InitializeInfo): State { - const { scheme, dataInit } = info; + function initialize(generalInitialization: GeneralInitialization): State { + const initialization: Initialization = Array.isArray(generalInitialization) + ? { scheme: { inputs: generalInitialization } } + : "scheme" in generalInitialization + ? generalInitialization + : { scheme: generalInitialization }; + + const { scheme, dataInit } = initialization; const { inputs, validator } = scheme; const keys = inputs.map((input) => input.key); @@ -185,8 +191,8 @@ export function useInputs(options: { } const values: InputValueDict = {}; - const disabled: InputDisabledDict = clean(info.dataInit?.disabled); - const dirties: InputDirtyDict = clean(info.dataInit?.dirties); + const disabled: InputDisabledDict = clean(dataInit?.disabled); + const dirties: InputDirtyDict = clean(dataInit?.dirties); for (let i = 0; i < inputs.length; i++) { const input = inputs[i]; @@ -195,7 +201,7 @@ export function useInputs(options: { values[key] = initializeValue(input, dataInit?.values?.[key]); } - let errors = info.dataInit?.errors; + let errors = dataInit?.errors; if (errors != null) { if (process.env.NODE_ENV === "development") { @@ -331,13 +337,13 @@ export function InputGroup({ )} > {inputs.map((item, index) => { - const { type, value, label, error, helper, disabled } = item; + const { key, type, value, label, error, helper, disabled } = item; const getContainerClassName = ( ...additionalClassNames: classNames.ArgumentArray ) => classNames( - `cru-input-container cru-input-${type}`, + `cru-input-container cru-input-type-${type}`, error && "error", ...additionalClassNames, ); @@ -350,7 +356,7 @@ export function InputGroup({ const { password } = item; return (
    {label && } @@ -369,7 +375,7 @@ export function InputGroup({ ); } else if (type === "bool") { return ( -
    +
    +