diff options
author | crupest <crupest@outlook.com> | 2023-07-20 20:44:15 +0800 |
---|---|---|
committer | crupest <crupest@outlook.com> | 2023-07-20 20:44:15 +0800 |
commit | 0e183074b326cf04a23ae1f1ba8dcc56166df485 (patch) | |
tree | 87963dbe54b018ee0573cd77e674d32c23d8ba7f | |
parent | adc91a81fe53fdbc3d63065baa0b56862c104824 (diff) | |
download | timeline-0e183074b326cf04a23ae1f1ba8dcc56166df485.tar.gz timeline-0e183074b326cf04a23ae1f1ba8dcc56166df485.tar.bz2 timeline-0e183074b326cf04a23ae1f1ba8dcc56166df485.zip |
...
23 files changed, 953 insertions, 495 deletions
diff --git a/FrontEnd/src/App.tsx b/FrontEnd/src/App.tsx index cfdab229..07a8780f 100644 --- a/FrontEnd/src/App.tsx +++ b/FrontEnd/src/App.tsx @@ -2,35 +2,25 @@ import * as React from "react"; 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 Center from "./views/center"; -import Home from "./views/home"; import Login from "./views/login"; import Register from "./views/register"; import Settings from "./views/settings"; -import About from "./views/about"; import TimelinePage from "./views/timeline"; import Search from "./views/search"; import Admin from "./views/admin"; import AlertHost from "./views/common/alert/AlertHost"; -import { useUser } from "./services/user"; - -const NoMatch: React.FC = () => { - return <div>Ah-oh, 404!</div>; -}; - -function App(): JSX.Element { - const user = useUser(); - +export default function App() { return ( <React.Suspense fallback={<LoadingPage />}> <BrowserRouter> <AppBar /> <div style={{ height: 56 }} /> <Routes> - <Route index element={user == null ? <Home /> : <Center />} /> - <Route path="home" element={<Home />} /> <Route path="center" element={<Center />} /> <Route path="login" element={<Login />} /> <Route path="register" element={<Register />} /> @@ -40,12 +30,10 @@ function App(): JSX.Element { <Route path="admin/*" element={<Admin />} /> <Route path=":owner" element={<TimelinePage />} /> <Route path=":owner/:timeline" element={<TimelinePage />} /> - <Route element={<NoMatch />} /> + <Route path="*" element={<NotFoundPage />} /> </Routes> <AlertHost /> </BrowserRouter> </React.Suspense> ); } - -export default App; diff --git a/FrontEnd/src/locales/en/translation.json b/FrontEnd/src/locales/en/translation.json index 21c826bd..95c722c9 100644 --- a/FrontEnd/src/locales/en/translation.json +++ b/FrontEnd/src/locales/en/translation.json @@ -224,23 +224,11 @@ } }, "about": { - "author": { - "title": "Site Developer", - "name": "Name: ", - "introduction": "Introduction: ", - "introductionContent": "A programmer coding based on coincidence", - "links": "Links: " - }, - "site": { - "title": "Site Information", - "content": "The name of this site is <1>Timeline</1>, which is a Web App with <3>timeline</3> as its core concept. Its frontend and backend are both developed by <5>me</5>, and open source on GitHub. It is relatively easy to deploy it on your own server, which is also one of my goals. Welcome to comment anything in GitHub repository.", - "repo": "GitHub Repo" - }, "credits": { "title": "Credits", - "content": "Timeline is works standing on shoulders of gaints. Special appreciation for many open source projects listed below or not. Related licenses could be found in GitHub repository.", - "frontend": "Frontend: ", - "backend": "Backend: " + "content": "Timeline stands on shoulders of giants. Special appreciation for many open source projects listed below or not. Related licenses could be found in GitHub repository.", + "frontend": "Frontend", + "backend": "Backend" } }, "admin": { 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 <div className="page-404">Ah-oh, 404!</div>; +} 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 ( + <div className="about-page"> + <h2>{c("about.credits.title")}</h2> + <p>{c("about.credits.content")}</p> + <h3>{c("about.credits.frontend")}</h3> + <ul> + {frontendCredits.map((item, index) => { + return ( + <li key={index}> + <a href={item.url} target="_blank" rel="noopener noreferrer"> + {item.name} + </a> + </li> + ); + })} + <li>...</li> + </ul> + <h3>{c("about.credits.backend")}</h3> + <ul> + {backendCredits.map((item, index) => { + return ( + <li key={index}> + <a href={item.url} target="_blank" rel="noopener noreferrer"> + {item.name} + </a> + </li> + ); + })} + <li>...</li> + </ul> + </div> + ); +} 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<ChangeAvatarDialogProps> = (props) => { + const { t } = useTranslation(); + + const user = useUser(); + + const [file, setFile] = React.useState<File | null>(null); + const [fileUrl, setFileUrl] = React.useState<string | null>(null); + const [clip, setClip] = React.useState<Clip | null>(null); + const [cropImgElement, setCropImgElement] = + React.useState<HTMLImageElement | null>(null); + const [resultBlob, setResultBlob] = React.useState<Blob | null>(null); + const [resultUrl, setResultUrl] = React.useState<string | null>(null); + + const [state, setState] = React.useState< + | "select" + | "crop" + | "processcrop" + | "preview" + | "uploading" + | "success" + | "error" + >("select"); + + const [message, setMessage] = useState<I18nText>( + "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<HTMLInputElement>): void => { + const files = e.target.files; + if (files == null || files.length === 0) { + setFile(null); + } else { + setFile(files[0]); + } + }, + [] + ); + + const onCropNext = React.useCallback(() => { + if ( + cropImgElement == null || + clip == null || + clip.width === 0 || + file == null + ) { + throw new UiLogicError(); + } + + setState("processcrop"); + void applyClipToImage(cropImgElement, clip, file.type).then((b) => { + setResultBlob(b); + }); + }, [cropImgElement, clip, file]); + + const onCropPrevious = React.useCallback(() => { + setFile(null); + setState("select"); + }, []); + + const onPreviewPrevious = React.useCallback(() => { + setResultBlob(null); + setState("crop"); + }, []); + + const upload = React.useCallback(() => { + if (resultBlob == null) { + throw new UiLogicError(); + } + + 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 ( + <div className="row justify-content-center"> + <div className="col col-auto"> + <img + className="change-avatar-img" + src={resultUrl} + alt={t("settings.dialogChangeAvatar.previewImgAlt") ?? undefined} + /> + </div> + </div> + ); + }; + + return ( + <Dialog open={props.open} onClose={close}> + <h3 className="cru-color-primary"> + {t("settings.dialogChangeAvatar.title")} + </h3> + <hr /> + {(() => { + if (state === "select") { + return ( + <> + <div className="container"> + <div className="row"> + {t("settings.dialogChangeAvatar.prompt.select")} + </div> + <div className="row"> + <input + className="px-0" + type="file" + accept="image/*" + onChange={onSelectFile} + /> + </div> + </div> + <hr /> + <div className="cru-dialog-bottom-area"> + <Button + text="operationDialog.cancel" + color="secondary" + onClick={close} + /> + </div> + </> + ); + } else if (state === "crop") { + if (fileUrl == null) { + throw new UiLogicError(); + } + return ( + <> + <div className="container"> + <div className="row justify-content-center"> + <ImageCropper + clip={clip} + onChange={setClip} + imageUrl={fileUrl} + imageElementCallback={setCropImgElement} + /> + </div> + <div className="row"> + {t("settings.dialogChangeAvatar.prompt.crop")} + </div> + </div> + <hr /> + <div className="cru-dialog-bottom-area"> + <Button + text="operationDialog.cancel" + color="secondary" + outline + onClick={close} + /> + <Button + text="operationDialog.previousStep" + color="secondary" + outline + onClick={onCropPrevious} + /> + <Button + text="operationDialog.nextStep" + color="primary" + onClick={onCropNext} + disabled={ + cropImgElement == null || clip == null || clip.width === 0 + } + /> + </div> + </> + ); + } else if (state === "processcrop") { + return ( + <> + <div className="container"> + <div className="row"> + {t("settings.dialogChangeAvatar.prompt.processingCrop")} + </div> + </div> + <hr /> + <div className="cru-dialog-bottom-area"> + <Button + text="operationDialog.cancel" + color="secondary" + onClick={close} + outline + /> + <Button + text="operationDialog.previousStep" + color="secondary" + onClick={onPreviewPrevious} + outline + /> + </div> + </> + ); + } else if (state === "preview") { + return ( + <> + <div className="container"> + {createPreviewRow()} + <div className="row"> + {t("settings.dialogChangeAvatar.prompt.preview")} + </div> + </div> + <hr /> + <div className="cru-dialog-bottom-area"> + <Button + text="operationDialog.cancel" + color="secondary" + outline + onClick={close} + /> + <Button + text="operationDialog.previousStep" + color="secondary" + outline + onClick={onPreviewPrevious} + /> + <Button + text="settings.dialogChangeAvatar.upload" + color="primary" + onClick={upload} + /> + </div> + </> + ); + } else if (state === "uploading") { + return ( + <> + <div className="container"> + {createPreviewRow()} + <div className="row"> + {t("settings.dialogChangeAvatar.prompt.uploading")} + </div> + </div> + </> + ); + } else if (state === "success") { + return ( + <> + <div className="container"> + <div className="row p-4 text-success"> + {t("operationDialog.success")} + </div> + </div> + <hr /> + <div className="cru-dialog-bottom-area"> + <Button + text="operationDialog.ok" + color="success" + onClick={close} + /> + </div> + </> + ); + } else { + return ( + <> + <div className="container"> + {createPreviewRow()} + <div className="row text-danger">{trueMessage}</div> + </div> + <hr /> + <div> + <Button + text="operationDialog.cancel" + color="secondary" + onClick={close} + /> + <Button + text="operationDialog.retry" + color="primary" + onClick={upload} + /> + </div> + </> + ); + } + })()} + </Dialog> + ); +}; + +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<ChangeNicknameDialogProps> = (props) => { + const user = useUser(); + + if (user == null) return null; + + return ( + <OperationDialog + open={props.open} + title="settings.dialogChangeNickname.title" + inputScheme={[ + { type: "text", label: "settings.dialogChangeNickname.inputLabel" }, + ]} + onProcess={([newNickname]) => { + return getHttpUserClient().patch(user.username, { + nickname: newNickname, + }); + }} + 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<ChangePasswordDialogProps> = (props) => { + const navigate = useNavigate(); + + const [redirect, setRedirect] = useState<boolean>(false); + + return ( + <OperationDialog + open={props.open} + title="settings.dialogChangePassword.title" + themeColor="danger" + inputPrompt="settings.dialogChangePassword.prompt" + inputScheme={[ + { + type: "text", + label: "settings.dialogChangePassword.inputOldPassword", + password: true, + }, + { + type: "text", + label: "settings.dialogChangePassword.inputNewPassword", + password: true, + }, + { + type: "text", + label: "settings.dialogChangePassword.inputRetypeNewPassword", + password: true, + }, + ]} + inputValidator={([oldPassword, newPassword, retypedNewPassword]) => { + const result: Record<number, string> = {}; + if (oldPassword === "") { + result[0] = "settings.dialogChangePassword.errorEmptyOldPassword"; + } + if (newPassword === "") { + result[1] = "settings.dialogChangePassword.errorEmptyNewPassword"; + } + if (retypedNewPassword !== newPassword) { + result[2] = "settings.dialogChangePassword.errorRetypeNotMatch"; + } + return result; + }} + onProcess={async ([oldPassword, newPassword]) => { + await userService.changePassword(oldPassword, newPassword); + setRedirect(true); + }} + 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 ( + <Card> + <h2 className="">{c(title)}</h2> + {children} + </Card> + ); +} + +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 ( + <div + style={style} + className={classNames( + "row settings-item mx-0", + first && "first", + onClick && "clickable", + className, + )} + onClick={onClick} + > + <div className="px-0 col col-auto"> + <div className={classNames(danger && "cru-color-danger")}> + {convertI18nText(title, t)} + </div> + <small className="d-block cru-color-secondary"> + {convertI18nText(subtext, t)} + </small> + </div> + <div className="col col-auto">{children}</div> + </div> + ); +} + +type ButtonSettingItemProps = SettingItemContainerWithoutChildrenProps; + +const ButtonSettingItem: React.FC<ButtonSettingItemProps> = ({ ...props }) => { + return <SettingItemContainer {...props} />; +}; + +interface SelectSettingItemProps + extends SettingItemContainerWithoutChildrenProps { + options: { + value: string; + label: I18nText; + }[]; + value?: string; + onSelect: (value: string) => void; +} + +const SelectSettingsItem: React.FC<SelectSettingItemProps> = ({ + options, + value, + onSelect, + ...props +}) => { + const { t } = useTranslation(); + + return ( + <SettingItemContainer {...props}> + {value == null ? ( + <Spinner /> + ) : ( + <select + value={value} + onChange={(e) => { + onSelect(e.target.value); + }} + > + {options.map(({ value, label }) => ( + <option key={value} value={value}> + {convertI18nText(label, t)} + </option> + ))} + </select> + )} + </SettingItemContainer> + ); +}; + +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 | null | string>( + undefined, + ); + + const [bookmarkVisibility, setBookmarkVisibility] = + useState<TimelineVisibility>(); + + 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 ( + <> + <div className="container"> + {user ? ( + <SettingSection title="settings.subheaders.account"> + <SettingItemContainer + title="settings.myRegisterCode" + subtext="settings.myRegisterCodeDesc" + onClick={() => setDialog("renewregistercode")} + > + {registerCode === undefined ? ( + <Spinner /> + ) : registerCode === null ? ( + <span>Noop</span> + ) : ( + <code + className="register-code" + onClick={(event) => { + void navigator.clipboard + .writeText(registerCode) + .then(() => { + pushAlert({ + type: "success", + message: "settings.myRegisterCodeCopied", + }); + }); + event.stopPropagation(); + }} + > + {registerCode} + </code> + )} + </SettingItemContainer> + <ButtonSettingItem + title="settings.changeAvatar" + onClick={() => setDialog("changeavatar")} + first + /> + <ButtonSettingItem + title="settings.changeNickname" + onClick={() => setDialog("changenickname")} + /> + <SelectSettingsItem + title="settings.changeBookmarkVisibility" + options={[ + { + value: "Private", + label: "visibility.private", + }, + { + value: "Register", + label: "visibility.register", + }, + { + value: "Public", + label: "visibility.public", + }, + ]} + value={bookmarkVisibility} + onSelect={(value) => { + void getHttpUserClient() + .putBookmarkVisibility(user.username, { + visibility: value as TimelineVisibility, + }) + .then(() => { + setBookmarkVisibility(value as TimelineVisibility); + }); + }} + /> + <ButtonSettingItem + title="settings.changePassword" + onClick={() => setDialog("changepassword")} + danger + /> + <ButtonSettingItem + title="settings.logout" + onClick={() => { + setDialog("logout"); + }} + danger + /> + </SettingSection> + ) : null} + <SettingSection title="settings.subheaders.customization"> + <SelectSettingsItem + title="settings.languagePrimary" + subtext="settings.languageSecondary" + options={[ + { + value: "zh", + label: { + type: "custom", + value: "中文", + }, + }, + { + value: "en", + label: { + type: "custom", + value: "English", + }, + }, + ]} + value={language} + onSelect={(value) => { + void i18n.changeLanguage(value); + }} + first + /> + </SettingSection> + </div> + <ChangePasswordDialog + open={dialog === "changepassword"} + close={() => setDialog(null)} + /> + <ConfirmDialog + title="settings.dialogConfirmLogout.title" + body="settings.dialogConfirmLogout.prompt" + onClose={() => setDialog(null)} + open={dialog === "logout"} + onConfirm={() => { + void userService.logout().then(() => { + navigate("/"); + }); + }} + /> + <ConfirmDialog + title="settings.renewRegisterCode" + body="settings.renewRegisterCodeDesc" + onClose={() => setDialog(null)} + open={dialog === "renewregistercode"} + onConfirm={() => { + if (user == null) throw new UiLogicError(); + void getHttpUserClient() + .renewRegisterCode(user.username) + .then(() => { + setRegisterCode(undefined); + }); + }} + /> + <ChangeAvatarDialog + open={dialog === "changeavatar"} + close={() => setDialog(null)} + /> + <ChangeNicknameDialog + open={dialog === "changenickname"} + close={() => setDialog(null)} + /> + </> + ); +}; + +export default SettingsPage; diff --git a/FrontEnd/src/views/about/author-avatar.png b/FrontEnd/src/views/about/author-avatar.png Binary files differdeleted file mode 100644 index d890d8d0..00000000 --- a/FrontEnd/src/views/about/author-avatar.png +++ /dev/null diff --git a/FrontEnd/src/views/about/github.png b/FrontEnd/src/views/about/github.png Binary files differdeleted file mode 100644 index ea6ff545..00000000 --- a/FrontEnd/src/views/about/github.png +++ /dev/null diff --git a/FrontEnd/src/views/about/index.css b/FrontEnd/src/views/about/index.css deleted file mode 100644 index 2574f4b7..00000000 --- a/FrontEnd/src/views/about/index.css +++ /dev/null @@ -1,4 +0,0 @@ -.about-link-icon {
- width: 1.2em;
- height: 1.2em;
-}
diff --git a/FrontEnd/src/views/about/index.tsx b/FrontEnd/src/views/about/index.tsx deleted file mode 100644 index 093da894..00000000 --- a/FrontEnd/src/views/about/index.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import { useTranslation, Trans } from "react-i18next"; - -import authorAvatarUrl from "./author-avatar.png"; -import githubLogoUrl from "./github.png"; - -import Card from "../common/Card"; - -import "./index.css"; - -const frontendCredits: { - name: string; - url: string; -}[] = [ - { - name: "reactjs", - url: "https://reactjs.org", - }, - { - name: "typescript", - url: "https://www.typescriptlang.org", - }, - { - name: "bootstrap", - url: "https://getbootstrap.com", - }, - { - name: "vite", - url: "https://vitejs.dev", - }, - { - name: "eslint", - url: "https://eslint.org", - }, - { - name: "prettier", - url: "https://prettier.io", - }, - { - name: "pepjs", - url: "https://github.com/jquery/PEP", - }, -]; - -const backendCredits: { - name: string; - url: string; -}[] = [ - { - 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 { t } = useTranslation(); - - return ( - <div className="px-2 mb-4"> - <Card className="container mt-4 py-3"> - <h4 id="author-info">{t("about.author.title")}</h4> - <div> - <div className="d-block"> - <img - src={authorAvatarUrl} - className="cru-avatar large cru-round cru-float-left" - /> - <p> - <small>{t("about.author.name")}</small> - <span className="cru-color-primary">crupest</span> - </p> - <p> - <small>{t("about.author.introduction")}</small> - {t("about.author.introductionContent")} - </p> - </div> - <p> - <small>{t("about.author.links")}</small> - <a - href="https://github.com/crupest" - target="_blank" - rel="noopener noreferrer" - > - <img src={githubLogoUrl} className="about-link-icon" /> - </a> - </p> - </div> - </Card> - <Card className="container mt-4 py-3"> - <h4>{t("about.site.title")}</h4> - <p> - <Trans i18nKey="about.site.content"> - 0<span className="cru-color-primary">1</span>2<b>3</b>4 - <a href="#author-info">5</a>6 - </Trans> - </p> - <p> - <a - href="https://github.com/crupest/Timeline" - target="_blank" - rel="noopener noreferrer" - > - {t("about.site.repo")} - </a> - </p> - </Card> - <Card className="container mt-4 py-3"> - <h4>{t("about.credits.title")}</h4> - <p>{t("about.credits.content")}</p> - <p>{t("about.credits.frontend")}</p> - <ul> - {frontendCredits.map((item, index) => { - return ( - <li key={index}> - <a href={item.url} target="_blank" rel="noopener noreferrer"> - {item.name} - </a> - </li> - ); - })} - <li>...</li> - </ul> - <p>{t("about.credits.backend")}</p> - <ul> - {backendCredits.map((item, index) => { - return ( - <li key={index}> - <a href={item.url} target="_blank" rel="noopener noreferrer"> - {item.name} - </a> - </li> - ); - })} - <li>...</li> - </ul> - </Card> - </div> - ); -} diff --git a/FrontEnd/src/views/common/Card.css b/FrontEnd/src/views/common/Card.css index fa470130..98cb4cdd 100644 --- a/FrontEnd/src/views/common/Card.css +++ b/FrontEnd/src/views/common/Card.css @@ -1,10 +1,9 @@ .cru-card {
- border: 1px solid;
- border-color: #e9ecef;
border-radius: var(--cru-card-border-radius);
+ background-color: var(--cru-primary-container-color);
transition: all 0.3s;
}
.cru-card:hover {
- border-color: var(--cru-primary-color);
-}
\ No newline at end of file + border-color: var(--cru-primary-1-color);
+}
diff --git a/FrontEnd/src/views/common/Card.tsx b/FrontEnd/src/views/common/Card.tsx index ebbce77e..50632006 100644 --- a/FrontEnd/src/views/common/Card.tsx +++ b/FrontEnd/src/views/common/Card.tsx @@ -1,19 +1,21 @@ +import { ComponentPropsWithoutRef, Ref } from "react"; import classNames from "classnames"; -import * as React from "react"; import "./Card.css"; -function _Card( - { - className, - children, - ...otherProps - }: React.PropsWithChildren<React.HTMLAttributes<HTMLDivElement>>, - ref: React.ForwardedRef<HTMLDivElement> -): React.ReactElement | null { +interface CardProps extends ComponentPropsWithoutRef<"div"> { + containerRef: Ref<HTMLDivElement>; +} + +export default function Card({ + className, + children, + containerRef, + ...otherProps +}: CardProps) { return ( <div - ref={ref} + ref={containerRef} className={classNames("cru-card", className)} {...otherProps} > @@ -21,7 +23,3 @@ function _Card( </div> ); } - -const Card = React.forwardRef(_Card); - -export default Card; diff --git a/FrontEnd/src/views/common/index.css b/FrontEnd/src/views/common/index.css index 789a0f05..eb82c4bf 100644 --- a/FrontEnd/src/views/common/index.css +++ b/FrontEnd/src/views/common/index.css @@ -10,10 +10,7 @@ body { font-family: var(--cru-default-font-family);
background: var(--cru-surface-color);
color: var(--cru-surface-on-color);
-}
-
-button {
- background-color: unset;
+ line-height: 1.2;
}
.cru-text-center {
diff --git a/FrontEnd/src/views/common/theme.css b/FrontEnd/src/views/common/theme.css index 9c9e1645..3ad45996 100644 --- a/FrontEnd/src/views/common/theme.css +++ b/FrontEnd/src/views/common/theme.css @@ -2,5 +2,5 @@ :root { --cru-default-font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; - --cru-card-border-radius: 8px; + --cru-card-border-radius: 4px; }
\ No newline at end of file diff --git a/FrontEnd/src/views/home/TimelineListView.tsx b/FrontEnd/src/views/home/TimelineListView.tsx deleted file mode 100644 index fbcdc9b0..00000000 --- a/FrontEnd/src/views/home/TimelineListView.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import * as React from "react"; -import { useTranslation } from "react-i18next"; -import { Link } from "react-router-dom"; - -import { convertI18nText, I18nText } from "@/common"; - -import { TimelineBookmark } from "@/http/bookmark"; - -import IconButton from "../common/button/IconButton"; - -interface TimelineListItemProps { - timeline: TimelineBookmark; -} - -const TimelineListItem: React.FC<TimelineListItemProps> = ({ timeline }) => { - return ( - <div className="home-timeline-list-item home-timeline-list-item-timeline"> - <svg className="home-timeline-list-item-line" viewBox="0 0 120 100"> - <path - d="M 80,50 m 0,-12 a 12 12 180 1 1 0,24 12 12 180 1 1 0,-24 z M 60,0 h 40 v 100 h -40 z" - fillRule="evenodd" - fill="#007bff" - /> - </svg> - <div> - {timeline.timelineOwner}/{timeline.timelineName} - </div> - <Link to={`${timeline.timelineOwner}/${timeline.timelineName}`}> - <IconButton icon="arrow-right" className="ms-3" /> - </Link> - </div> - ); -}; - -const TimelineListArrow: React.FC = () => { - return ( - <div> - <div className="home-timeline-list-item"> - <svg className="home-timeline-list-item-line" viewBox="0 0 120 60"> - <path d="M 60,0 h 40 v 20 l -20,20 l -20,-20 z" fill="#007bff" /> - </svg> - </div> - <div className="home-timeline-list-item"> - <svg - className="home-timeline-list-item-line home-timeline-list-loading-head" - viewBox="0 0 120 40" - > - <path - d="M 60,10 l 20,20 l 20,-20" - fill="none" - stroke="#007bff" - strokeWidth="5" - /> - </svg> - </div> - </div> - ); -}; - -interface TimelineListViewProps { - headerText?: I18nText; - timelines?: TimelineBookmark[]; -} - -const TimelineListView: React.FC<TimelineListViewProps> = ({ - headerText, - timelines, -}) => { - const { t } = useTranslation(); - - return ( - <div className="home-timeline-list"> - <div className="home-timeline-list-item"> - <svg className="home-timeline-list-item-line" viewBox="0 0 120 120"> - <path - d="M 0,20 Q 80,20 80,80 l 0,40" - stroke="#007bff" - strokeWidth="40" - fill="none" - /> - </svg> - <h3>{convertI18nText(headerText, t)}</h3> - </div> - {timelines != null - ? timelines.map((t) => ( - <TimelineListItem - key={`${t.timelineOwner}/${t.timelineName}`} - timeline={t} - /> - )) - : null} - <TimelineListArrow /> - </div> - ); -}; - -export default TimelineListView; diff --git a/FrontEnd/src/views/home/WebsiteIntroduction.tsx b/FrontEnd/src/views/home/WebsiteIntroduction.tsx deleted file mode 100644 index e843c325..00000000 --- a/FrontEnd/src/views/home/WebsiteIntroduction.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import * as React from "react"; -import { useTranslation } from "react-i18next"; -import { Link } from "react-router-dom"; - -const WebsiteIntroduction: React.FC<{ - className?: string; - style?: React.CSSProperties; -}> = ({ className, style }) => { - const { i18n } = useTranslation(); - - if (i18n.language.startsWith("zh")) { - return ( - <div className={className} style={style}> - <h2> - 欢迎来到<strong>时间线</strong>!🎉🎉🎉 - </h2> - <p> - 本网站由无数个独立的时间线构成,每一个时间线都是一个消息列表,类似于一个聊天软件(比如QQ)。 - </p> - <p> - 如果你拥有一个账号,<Link to="/login">登陆</Link> - 后你可以自由地在属于你的时间线中发送内容,支持markdown和上传图片哦!你可以创建一个新的时间线来开启一个新的话题。你也可以设置相关权限,只让一部分人能看到时间线的内容。 - </p> - <p> - 如果你没有账号,那么你可以去浏览一下公开的时间线,比如下面这些站长设置的高光时间线。 - </p> - <p> - 鉴于这个网站在我的小型服务器上部署,所以没有开放注册。如果你也想把这个服务部署到自己的服务器上,你可以在 - <Link to="/about">关于</Link>页面找到一些信息。 - </p> - <p> - <small className="text-secondary"> - 这一段介绍是我的对象抱怨多次我的网站他根本看不明白之后加的,希望你能顺利看懂这个网站的逻辑!😅 - </small> - </p> - </div> - ); - } else { - return ( - <div className={className} style={style}> - <h2> - Welcome to <strong>Timeline</strong>!🎉🎉🎉 - </h2> - <p> - This website consists of many individual timelines. Each timeline is a - list of messages just like a chat app. - </p> - <p> - If you do have an account, you can <Link to="/login">login</Link> and - post messages, which supports Markdown and images, in your timelines. - You can also create a new timeline to open a new topic. You can set - the permission of a timeline to only allow specified people to see the - content of the timeline. - </p> - <p> - If you don't have an account, you can view some public timelines - like highlight timelines below set by website manager. - </p> - <p> - Since this website is hosted on my tiny server, so account registry is - not opened. If you want to host this service on your own server, you - can find some useful information on <Link to="/about">about</Link>{" "} - page. - </p> - <p> - <small className="text-secondary"> - This introduction is added after my lover complained a lot of times - about the obscuration of my website. May you understand the logic of - it!😅 - </small> - </p> - </div> - ); - } -}; - -export default WebsiteIntroduction; diff --git a/FrontEnd/src/views/home/index.css b/FrontEnd/src/views/home/index.css deleted file mode 100644 index 89d36f0d..00000000 --- a/FrontEnd/src/views/home/index.css +++ /dev/null @@ -1,42 +0,0 @@ -.home-timeline-list-item {
- display: flex;
- align-items: center;
-}
-
-.home-timeline-list-item-timeline {
- transition: background 0.8s;
- animation: 0.8s home-timeline-list-item-timeline-enter;
-}
-.home-timeline-list-item-timeline:hover {
- background: #e9ecef;
-}
-
-@keyframes home-timeline-list-item-timeline-enter {
- from {
- transform: translate(-100%, 0);
- opacity: 0;
- }
-}
-.home-timeline-list-item-line {
- width: 80px;
- flex-shrink: 0;
-}
-
-@keyframes home-timeline-list-loading-head-animation {
- from {
- transform: translate(0, -30px);
- opacity: 1;
- }
- to {
- opacity: 0;
- }
-}
-.home-timeline-list-loading-head {
- animation: 1s infinite home-timeline-list-loading-head-animation;
-}
-
-@media (min-width: 576px) {
- .home-search {
- float: right;
- }
-}
diff --git a/FrontEnd/src/views/home/index.tsx b/FrontEnd/src/views/home/index.tsx deleted file mode 100644 index 3c80fb0c..00000000 --- a/FrontEnd/src/views/home/index.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import * as React from "react"; -import { useNavigate } from "react-router-dom"; - -import { highlightTimelineUsername } from "@/common"; - -import { Page } from "@/http/common"; -import { getHttpBookmarkClient, TimelineBookmark } from "@/http/bookmark"; - -import SearchInput from "../common/SearchInput"; -import TimelineListView from "./TimelineListView"; -import WebsiteIntroduction from "./WebsiteIntroduction"; - -import "./index.css"; - -const highlightTimelineMessageMap = { - loading: "home.loadingHighlightTimelines", - done: "home.loadedHighlightTimelines", - error: "home.errorHighlightTimelines", -} as const; - -const HomeV2: React.FC = () => { - const navigate = useNavigate(); - - const [navText, setNavText] = React.useState<string>(""); - - const [highlightTimelineState, setHighlightTimelineState] = React.useState< - "loading" | "done" | "error" - >("loading"); - const [highlightTimelines, setHighlightTimelines] = React.useState< - Page<TimelineBookmark> | undefined - >(); - - React.useEffect(() => { - if (highlightTimelineState === "loading") { - let subscribe = true; - void getHttpBookmarkClient() - .list(highlightTimelineUsername) - .then( - (data) => { - if (subscribe) { - setHighlightTimelineState("done"); - setHighlightTimelines(data); - } - }, - () => { - if (subscribe) { - setHighlightTimelineState("error"); - setHighlightTimelines(undefined); - } - } - ); - return () => { - subscribe = false; - }; - } - }, [highlightTimelineState]); - - return ( - <> - <SearchInput - className="mx-2 my-3 home-search" - value={navText} - onChange={setNavText} - onButtonClick={() => { - navigate(`search?q=${navText}`); - }} - alwaysOneline - /> - <WebsiteIntroduction className="m-2" /> - <TimelineListView - headerText={highlightTimelineMessageMap[highlightTimelineState]} - timelines={highlightTimelines?.items} - /> - </> - ); -}; - -export default HomeV2; |