diff options
Diffstat (limited to 'FrontEnd/src/pages/setting')
-rw-r--r-- | FrontEnd/src/pages/setting/ChangeAvatarDialog.css | 22 | ||||
-rw-r--r-- | FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx | 276 | ||||
-rw-r--r-- | FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx | 26 | ||||
-rw-r--r-- | FrontEnd/src/pages/setting/ChangePasswordDialog.tsx | 70 | ||||
-rw-r--r-- | FrontEnd/src/pages/setting/index.css | 76 | ||||
-rw-r--r-- | FrontEnd/src/pages/setting/index.tsx | 297 |
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> + ); +} |