aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--FrontEnd/src/App.tsx20
-rw-r--r--FrontEnd/src/locales/en/translation.json18
-rw-r--r--FrontEnd/src/pages/404/index.css7
-rw-r--r--FrontEnd/src/pages/404/index.tsx5
-rw-r--r--FrontEnd/src/pages/about/index.css8
-rw-r--r--FrontEnd/src/pages/about/index.tsx86
-rw-r--r--FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx354
-rw-r--r--FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx34
-rw-r--r--FrontEnd/src/pages/setting/ChangePasswordDialog.tsx69
-rw-r--r--FrontEnd/src/pages/setting/index.css31
-rw-r--r--FrontEnd/src/pages/setting/index.tsx335
-rw-r--r--FrontEnd/src/views/about/author-avatar.pngbin12038 -> 0 bytes
-rw-r--r--FrontEnd/src/views/about/github.pngbin4268 -> 0 bytes
-rw-r--r--FrontEnd/src/views/about/index.css4
-rw-r--r--FrontEnd/src/views/about/index.tsx143
-rw-r--r--FrontEnd/src/views/common/Card.css7
-rw-r--r--FrontEnd/src/views/common/Card.tsx26
-rw-r--r--FrontEnd/src/views/common/index.css5
-rw-r--r--FrontEnd/src/views/common/theme.css2
-rw-r--r--FrontEnd/src/views/home/TimelineListView.tsx97
-rw-r--r--FrontEnd/src/views/home/WebsiteIntroduction.tsx77
-rw-r--r--FrontEnd/src/views/home/index.css42
-rw-r--r--FrontEnd/src/views/home/index.tsx78
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
deleted file mode 100644
index d890d8d0..00000000
--- a/FrontEnd/src/views/about/author-avatar.png
+++ /dev/null
Binary files differ
diff --git a/FrontEnd/src/views/about/github.png b/FrontEnd/src/views/about/github.png
deleted file mode 100644
index ea6ff545..00000000
--- a/FrontEnd/src/views/about/github.png
+++ /dev/null
Binary files differ
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&apos;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;