From 0e183074b326cf04a23ae1f1ba8dcc56166df485 Mon Sep 17 00:00:00 2001 From: crupest Date: Thu, 20 Jul 2023 20:44:15 +0800 Subject: ... --- FrontEnd/src/pages/404/index.css | 7 + FrontEnd/src/pages/404/index.tsx | 5 + FrontEnd/src/pages/about/index.css | 8 + FrontEnd/src/pages/about/index.tsx | 86 +++++ 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 +++++++++++++++++++ 9 files changed, 929 insertions(+) create mode 100644 FrontEnd/src/pages/404/index.css create mode 100644 FrontEnd/src/pages/404/index.tsx create mode 100644 FrontEnd/src/pages/about/index.css create mode 100644 FrontEnd/src/pages/about/index.tsx 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') diff --git a/FrontEnd/src/pages/404/index.css b/FrontEnd/src/pages/404/index.css new file mode 100644 index 00000000..cf5efbe7 --- /dev/null +++ b/FrontEnd/src/pages/404/index.css @@ -0,0 +1,7 @@ +.page-404 { + width: 100%; + text-align: center; + padding-top: 1em; + font-size: 2em; + color: var(--cru-danger-color); +} \ No newline at end of file diff --git a/FrontEnd/src/pages/404/index.tsx b/FrontEnd/src/pages/404/index.tsx new file mode 100644 index 00000000..751a450b --- /dev/null +++ b/FrontEnd/src/pages/404/index.tsx @@ -0,0 +1,5 @@ +import "./index.css"; + +export default function NotFoundPage() { + return
Ah-oh, 404!
; +} diff --git a/FrontEnd/src/pages/about/index.css b/FrontEnd/src/pages/about/index.css new file mode 100644 index 00000000..487f4a0a --- /dev/null +++ b/FrontEnd/src/pages/about/index.css @@ -0,0 +1,8 @@ +.about-page { + padding: 1em 2em; + line-height: 1.5; +} + +.about-page a { + color: var(--cru-surface-on-color); +} diff --git a/FrontEnd/src/pages/about/index.tsx b/FrontEnd/src/pages/about/index.tsx new file mode 100644 index 00000000..afd4de34 --- /dev/null +++ b/FrontEnd/src/pages/about/index.tsx @@ -0,0 +1,86 @@ +import "./index.css"; + +import { useC } from "@/common"; + +interface Credit { + name: string; + url: string; +} + +type Credits = Credit[]; + +const frontendCredits: Credits = [ + { + name: "react.js", + url: "https://reactjs.org", + }, + { + name: "typescript", + url: "https://www.typescriptlang.org", + }, + { + name: "bootstrap", + url: "https://getbootstrap.com", + }, + { + name: "parcel.js", + url: "https://parceljs.org", + }, + { + name: "eslint", + url: "https://eslint.org", + }, + { + name: "prettier", + url: "https://prettier.io", + }, +]; + +const backendCredits: Credits = [ + { + name: "ASP.NET Core", + url: "https://dotnet.microsoft.com/learn/aspnet/what-is-aspnet-core", + }, + { name: "sqlite", url: "https://sqlite.org" }, + { + name: "ImageSharp", + url: "https://github.com/SixLabors/ImageSharp", + }, +]; + +export default function AboutPage() { + const c = useC(); + + return ( +
+

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

+

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

+

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

+ +

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

+ +
+ ); +} 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