aboutsummaryrefslogtreecommitdiff
path: root/FrontEnd/src/pages
diff options
context:
space:
mode:
Diffstat (limited to 'FrontEnd/src/pages')
-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.css7
-rw-r--r--FrontEnd/src/pages/about/index.tsx87
-rw-r--r--FrontEnd/src/pages/home/index.css13
-rw-r--r--FrontEnd/src/pages/home/index.tsx12
-rw-r--r--FrontEnd/src/pages/loading/index.css7
-rw-r--r--FrontEnd/src/pages/loading/index.tsx11
-rw-r--r--FrontEnd/src/pages/login/index.css14
-rw-r--r--FrontEnd/src/pages/login/index.tsx127
-rw-r--r--FrontEnd/src/pages/register/index.css5
-rw-r--r--FrontEnd/src/pages/register/index.tsx130
-rw-r--r--FrontEnd/src/pages/setting/ChangeAvatarDialog.css22
-rw-r--r--FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx276
-rw-r--r--FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx26
-rw-r--r--FrontEnd/src/pages/setting/ChangePasswordDialog.tsx70
-rw-r--r--FrontEnd/src/pages/setting/index.css76
-rw-r--r--FrontEnd/src/pages/setting/index.tsx297
-rw-r--r--FrontEnd/src/pages/timeline/ConnectionStatusBadge.css38
-rw-r--r--FrontEnd/src/pages/timeline/ConnectionStatusBadge.tsx36
-rw-r--r--FrontEnd/src/pages/timeline/Timeline.css42
-rw-r--r--FrontEnd/src/pages/timeline/Timeline.tsx180
-rw-r--r--FrontEnd/src/pages/timeline/TimelineDateLabel.css9
-rw-r--r--FrontEnd/src/pages/timeline/TimelineDateLabel.tsx13
-rw-r--r--FrontEnd/src/pages/timeline/TimelineDeleteDialog.tsx54
-rw-r--r--FrontEnd/src/pages/timeline/TimelineInfoCard.css63
-rw-r--r--FrontEnd/src/pages/timeline/TimelineInfoCard.tsx208
-rw-r--r--FrontEnd/src/pages/timeline/TimelineMember.css20
-rw-r--r--FrontEnd/src/pages/timeline/TimelineMember.tsx189
-rw-r--r--FrontEnd/src/pages/timeline/TimelinePostCard.css9
-rw-r--r--FrontEnd/src/pages/timeline/TimelinePostCard.tsx22
-rw-r--r--FrontEnd/src/pages/timeline/TimelinePostContainer.css3
-rw-r--r--FrontEnd/src/pages/timeline/TimelinePostContainer.tsx20
-rw-r--r--FrontEnd/src/pages/timeline/TimelinePostList.css10
-rw-r--r--FrontEnd/src/pages/timeline/TimelinePostList.tsx75
-rw-r--r--FrontEnd/src/pages/timeline/TimelinePostView.css37
-rw-r--r--FrontEnd/src/pages/timeline/TimelinePostView.tsx123
-rw-r--r--FrontEnd/src/pages/timeline/TimelinePropertyChangeDialog.tsx79
-rw-r--r--FrontEnd/src/pages/timeline/edit/ImagePostEdit.css5
-rw-r--r--FrontEnd/src/pages/timeline/edit/ImagePostEdit.tsx36
-rw-r--r--FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.css24
-rw-r--r--FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.tsx199
-rw-r--r--FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.css12
-rw-r--r--FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.tsx29
-rw-r--r--FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.css35
-rw-r--r--FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.tsx193
-rw-r--r--FrontEnd/src/pages/timeline/index.tsx16
-rw-r--r--FrontEnd/src/pages/timeline/view/ImagePostView.css0
-rw-r--r--FrontEnd/src/pages/timeline/view/ImagePostView.tsx38
-rw-r--r--FrontEnd/src/pages/timeline/view/MarkdownPostView.css4
-rw-r--r--FrontEnd/src/pages/timeline/view/MarkdownPostView.tsx59
-rw-r--r--FrontEnd/src/pages/timeline/view/PlainTextPostView.css0
-rw-r--r--FrontEnd/src/pages/timeline/view/PlainTextPostView.tsx50
-rw-r--r--FrontEnd/src/pages/timeline/view/TimelinePostContentView.tsx37
54 files changed, 3159 insertions, 0 deletions
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..1ce7a7c8
--- /dev/null
+++ b/FrontEnd/src/pages/about/index.css
@@ -0,0 +1,7 @@
+.about-page {
+ 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..bce64322
--- /dev/null
+++ b/FrontEnd/src/pages/about/index.tsx
@@ -0,0 +1,87 @@
+import "./index.css";
+
+import { useC } from "~src/common";
+import Page from "~src/components/Page";
+
+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 (
+ <Page 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>
+ </Page>
+ );
+}
diff --git a/FrontEnd/src/pages/home/index.css b/FrontEnd/src/pages/home/index.css
new file mode 100644
index 00000000..16601d8a
--- /dev/null
+++ b/FrontEnd/src/pages/home/index.css
@@ -0,0 +1,13 @@
+.home-page {
+ width: 100%;
+ text-align: center;
+ padding-top: 1em;
+ font-size: 2em;
+ color: var(--cru-primary-color);
+}
+
+.home-page-2 {
+ width: 100%;
+ text-align: center;
+ margin-top: 2em;
+}
diff --git a/FrontEnd/src/pages/home/index.tsx b/FrontEnd/src/pages/home/index.tsx
new file mode 100644
index 00000000..c29a1ca5
--- /dev/null
+++ b/FrontEnd/src/pages/home/index.tsx
@@ -0,0 +1,12 @@
+import "./index.css";
+
+export default function HomePage() {
+ return (
+ <>
+ <div className="home-page">Be patient! I&apos;m working on this...</div>
+ <div className="home-page-2">
+ Have a look at <a href="/crupest">here</a>!
+ </div>
+ </>
+ );
+}
diff --git a/FrontEnd/src/pages/loading/index.css b/FrontEnd/src/pages/loading/index.css
new file mode 100644
index 00000000..08e43c22
--- /dev/null
+++ b/FrontEnd/src/pages/loading/index.css
@@ -0,0 +1,7 @@
+.loading-page {
+ width: 100%;
+ text-align: center;
+ padding-top: 1em;
+ font-size: 2em;
+ color: var(--cru-primary-color);
+} \ No newline at end of file
diff --git a/FrontEnd/src/pages/loading/index.tsx b/FrontEnd/src/pages/loading/index.tsx
new file mode 100644
index 00000000..29d27adc
--- /dev/null
+++ b/FrontEnd/src/pages/loading/index.tsx
@@ -0,0 +1,11 @@
+import Spinner from "~src/components/Spinner";
+
+import "./index.css";
+
+export default function LoadingPage() {
+ return (
+ <div className="loading-page">
+ <Spinner />
+ </div>
+ );
+}
diff --git a/FrontEnd/src/pages/login/index.css b/FrontEnd/src/pages/login/index.css
new file mode 100644
index 00000000..ef97359c
--- /dev/null
+++ b/FrontEnd/src/pages/login/index.css
@@ -0,0 +1,14 @@
+.login-page {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+.login-page-welcome {
+ text-align: center;
+ font-size: 2em;
+}
+
+.login-page-error {
+ color: var(--cru-danger-color);
+} \ No newline at end of file
diff --git a/FrontEnd/src/pages/login/index.tsx b/FrontEnd/src/pages/login/index.tsx
new file mode 100644
index 00000000..39ea3831
--- /dev/null
+++ b/FrontEnd/src/pages/login/index.tsx
@@ -0,0 +1,127 @@
+import { useState, useEffect } from "react";
+import { Link, useNavigate } from "react-router-dom";
+import { Trans } from "react-i18next";
+
+import { useUser, userService } from "~src/services/user";
+
+import { useC } from "~src/components/common";
+import LoadingButton from "~src/components/button/LoadingButton";
+import { InputGroup, useInputs } from "~src/components/input/InputGroup";
+import Page from "~src/components/Page";
+
+import "./index.css";
+
+export default function LoginPage() {
+ const c = useC();
+
+ const user = useUser();
+
+ const navigate = useNavigate();
+
+ const [process, setProcess] = useState<boolean>(false);
+ const [error, setError] = useState<string | null>(null);
+
+ const { hasErrorAndDirty, confirm, setAllDisabled, inputGroupProps } =
+ useInputs({
+ init: {
+ scheme: {
+ inputs: [
+ {
+ key: "username",
+ type: "text",
+ label: "user.username",
+ },
+ {
+ key: "password",
+ type: "text",
+ label: "user.password",
+ password: true,
+ },
+ {
+ key: "rememberMe",
+ type: "bool",
+ label: "user.rememberMe",
+ },
+ ],
+ validator: ({ username, password }, errors) => {
+ if (username === "") {
+ errors["username"] = "login.emptyUsername";
+ }
+ if (password === "") {
+ errors["password"] = "login.emptyPassword";
+ }
+ },
+ },
+ dataInit: {},
+ },
+ });
+
+ useEffect(() => {
+ if (user != null) {
+ const id = setTimeout(() => navigate("/"), 3000);
+ return () => {
+ clearTimeout(id);
+ };
+ }
+ }, [navigate, user]);
+
+ if (user != null) {
+ return <p>{c("login.alreadyLogin")}</p>;
+ }
+
+ const submit = (): void => {
+ const confirmResult = confirm();
+ if (confirmResult.type === "ok") {
+ const { username, password, rememberMe } = confirmResult.values;
+ setAllDisabled(true);
+ setProcess(true);
+ userService
+ .login(
+ {
+ username: username as string,
+ password: password as string,
+ },
+ rememberMe as boolean,
+ )
+ .then(
+ () => {
+ if (history.length === 0) {
+ navigate("/");
+ } else {
+ navigate(-1);
+ }
+ },
+ (e: Error) => {
+ setProcess(false);
+ setAllDisabled(false);
+ setError(e.message);
+ },
+ );
+ }
+ };
+
+ return (
+ <Page className="login-page">
+ <div className="login-page-container">
+ <div className="login-page-welcome">{c("welcome")}</div>
+ <InputGroup {...inputGroupProps} />
+ {error ? <p className="login-page-error">{c(error)}</p> : null}
+ <div className="login-page-button-row">
+ <LoadingButton
+ loading={process}
+ onClick={(e) => {
+ submit();
+ e.preventDefault();
+ }}
+ disabled={hasErrorAndDirty}
+ >
+ {c("user.login")}
+ </LoadingButton>
+ </div>
+ <Trans i18nKey="login.noAccount">
+ 0<Link to="/register">1</Link>2
+ </Trans>
+ </div>
+ </Page>
+ );
+}
diff --git a/FrontEnd/src/pages/register/index.css b/FrontEnd/src/pages/register/index.css
new file mode 100644
index 00000000..c0078b28
--- /dev/null
+++ b/FrontEnd/src/pages/register/index.css
@@ -0,0 +1,5 @@
+.register-page {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
diff --git a/FrontEnd/src/pages/register/index.tsx b/FrontEnd/src/pages/register/index.tsx
new file mode 100644
index 00000000..fa25c2c2
--- /dev/null
+++ b/FrontEnd/src/pages/register/index.tsx
@@ -0,0 +1,130 @@
+import { useState, useEffect } from "react";
+import { useTranslation } from "react-i18next";
+import { useNavigate } from "react-router-dom";
+
+import { HttpBadRequestError } from "~src/http/common";
+import { getHttpTokenClient } from "~src/http/token";
+import { userService, useUser } from "~src/services/user";
+
+import { LoadingButton } from "~src/components/button";
+import { useInputs, InputGroup } from "~src/components/input/InputGroup";
+
+import "./index.css";
+
+export default function RegisterPage() {
+ const navigate = useNavigate();
+
+ const { t } = useTranslation();
+
+ const user = useUser();
+
+ const { hasErrorAndDirty, confirm, setAllDisabled, inputGroupProps } =
+ useInputs({
+ init: {
+ scheme: {
+ inputs: [
+ {
+ key: "username",
+ type: "text",
+ label: "register.username",
+ },
+ {
+ key: "password",
+ type: "text",
+ label: "register.password",
+ password: true,
+ },
+ {
+ key: "confirmPassword",
+ type: "text",
+ label: "register.confirmPassword",
+ password: true,
+ },
+ {
+ key: "registerCode",
+
+ type: "text",
+ label: "register.registerCode",
+ },
+ ],
+ validator: (
+ { username, password, confirmPassword, registerCode },
+ errors,
+ ) => {
+ if (username === "") {
+ errors["username"] = "register.error.usernameEmpty";
+ }
+ if (password === "") {
+ errors["password"] = "register.error.passwordEmpty";
+ }
+ if (confirmPassword !== password) {
+ errors["confirmPassword"] = "register.error.confirmPasswordWrong";
+ }
+ if (registerCode === "") {
+ errors["registerCode"] = "register.error.registerCodeEmpty";
+ }
+ },
+ },
+ dataInit: {},
+ },
+ });
+
+ const [process, setProcess] = useState<boolean>(false);
+ const [resultError, setResultError] = useState<string | null>(null);
+
+ useEffect(() => {
+ if (user != null) {
+ navigate("/");
+ }
+ }, [navigate, user]);
+
+ return (
+ <div className="container register-page">
+ <InputGroup {...inputGroupProps} />
+ {resultError && <div className="cru-color-danger">{t(resultError)}</div>}
+ <LoadingButton
+ text="register.register"
+ loading={process}
+ disabled={hasErrorAndDirty}
+ onClick={() => {
+ const confirmResult = confirm();
+ if (confirmResult.type === "ok") {
+ const { username, password, registerCode } = confirmResult.values;
+ setProcess(true);
+ setAllDisabled(true);
+ void getHttpTokenClient()
+ .register({
+ username: username as string,
+ password: password as string,
+ registerCode: registerCode as string,
+ })
+ .then(
+ () => {
+ void userService
+ .login(
+ {
+ username: username as string,
+ password: password as string,
+ },
+ true,
+ )
+ .then(() => {
+ navigate("/");
+ });
+ },
+ (error) => {
+ if (error instanceof HttpBadRequestError) {
+ setResultError("register.error.registerCodeInvalid");
+ } else {
+ setResultError("error.network");
+ }
+ setProcess(false);
+ setAllDisabled(false);
+ },
+ );
+ }
+ }}
+ />
+ </div>
+ );
+}
diff --git a/FrontEnd/src/pages/setting/ChangeAvatarDialog.css b/FrontEnd/src/pages/setting/ChangeAvatarDialog.css
new file mode 100644
index 00000000..c9eb8011
--- /dev/null
+++ b/FrontEnd/src/pages/setting/ChangeAvatarDialog.css
@@ -0,0 +1,22 @@
+.change-avatar-dialog-prompt {
+ margin: 0.5em 0;
+}
+
+.change-avatar-dialog-prompt.success {
+ color: var(--cru-create-color);
+}
+
+.change-avatar-dialog-prompt.error {
+ color: var(--cru-danger-color);
+}
+
+.change-avatar-cropper {
+ max-width: 400px;
+ max-height: 400px;
+}
+
+.change-avatar-preview-image {
+ min-width: 50%;
+ max-width: 100%;
+ max-height: 300px;
+} \ No newline at end of file
diff --git a/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx b/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx
new file mode 100644
index 00000000..0df10411
--- /dev/null
+++ b/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx
@@ -0,0 +1,276 @@
+import { useState, ChangeEvent, ComponentPropsWithoutRef } from "react";
+
+import { useC, Text, UiLogicError } from "~src/common";
+
+import { useUser } from "~src/services/user";
+
+import { getHttpUserClient } from "~src/http/user";
+
+import { ImageCropper, useImageCrop } from "~src/components/ImageCropper";
+import BlobImage from "~src/components/BlobImage";
+import { ButtonRowV2 } from "~src/components/button";
+import {
+ Dialog,
+ DialogContainer,
+ useDialogController,
+} from "~src/components/dialog";
+
+import "./ChangeAvatarDialog.css";
+
+export default function ChangeAvatarDialog() {
+ const c = useC();
+
+ const user = useUser();
+
+ const controller = useDialogController();
+
+ type State =
+ | "select"
+ | "crop"
+ | "process-crop"
+ | "preview"
+ | "uploading"
+ | "success"
+ | "error";
+ const [state, setState] = useState<State>("select");
+
+ const [file, setFile] = useState<File | null>(null);
+
+ const { canCrop, crop, imageCropperProps } = useImageCrop(file, {
+ constraint: {
+ ratio: 1,
+ },
+ });
+
+ const [resultBlob, setResultBlob] = useState<Blob | null>(null);
+ const [message, setMessage] = useState<Text>(
+ "settings.dialogChangeAvatar.prompt.select",
+ );
+
+ const close = controller.closeDialog;
+
+ const onSelectFile = (e: ChangeEvent<HTMLInputElement>): void => {
+ const files = e.target.files;
+ if (files == null || files.length === 0) {
+ setFile(null);
+ } else {
+ setFile(files[0]);
+ }
+ };
+
+ const onCropNext = () => {
+ if (!canCrop) {
+ throw new UiLogicError();
+ }
+
+ setState("process-crop");
+
+ void crop().then((b) => {
+ setState("preview");
+ setResultBlob(b);
+ });
+ };
+
+ const onCropPrevious = () => {
+ setFile(null);
+ setState("select");
+ };
+
+ const onPreviewPrevious = () => {
+ setState("crop");
+ };
+
+ const upload = () => {
+ if (resultBlob == null) {
+ throw new UiLogicError();
+ }
+
+ if (user == null) {
+ throw new UiLogicError();
+ }
+
+ setState("uploading");
+ controller.setCanSwitchDialog(false);
+ getHttpUserClient()
+ .putAvatar(user.username, resultBlob)
+ .then(
+ () => {
+ setState("success");
+ },
+ () => {
+ setState("error");
+ setMessage("operationDialog.error");
+ },
+ )
+ .finally(() => {
+ controller.setCanSwitchDialog(true);
+ });
+ };
+
+ const cancelButton = {
+ key: "cancel",
+ text: "operationDialog.cancel",
+ onClick: close,
+ } as const;
+
+ const createPreviousButton = (onClick: () => void) =>
+ ({
+ key: "previous",
+ text: "operationDialog.previousStep",
+ onClick,
+ }) as const;
+
+ const buttonsMap: Record<
+ State,
+ ComponentPropsWithoutRef<typeof ButtonRowV2>["buttons"]
+ > = {
+ select: [
+ cancelButton,
+ {
+ key: "next",
+ action: "major",
+ text: "operationDialog.nextStep",
+ onClick: () => setState("crop"),
+ disabled: file == null,
+ },
+ ],
+ crop: [
+ cancelButton,
+ createPreviousButton(onCropPrevious),
+ {
+ key: "next",
+ action: "major",
+ text: "operationDialog.nextStep",
+ onClick: onCropNext,
+ disabled: !canCrop,
+ },
+ ],
+ "process-crop": [cancelButton, createPreviousButton(onPreviewPrevious)],
+ preview: [
+ cancelButton,
+ createPreviousButton(onPreviewPrevious),
+ {
+ key: "upload",
+ action: "major",
+ text: "settings.dialogChangeAvatar.upload",
+ onClick: upload,
+ },
+ ],
+ uploading: [],
+ success: [
+ {
+ key: "ok",
+ text: "operationDialog.ok",
+ color: "create",
+ onClick: close,
+ },
+ ],
+ error: [
+ cancelButton,
+ {
+ key: "retry",
+ action: "major",
+ text: "operationDialog.retry",
+ onClick: upload,
+ },
+ ],
+ };
+
+ return (
+ <Dialog>
+ <DialogContainer
+ title="settings.dialogChangeAvatar.title"
+ titleColor="primary"
+ buttonsV2={buttonsMap[state]}
+ >
+ {(() => {
+ if (state === "select") {
+ return (
+ <div className="change-avatar-dialog-container">
+ <div className="change-avatar-dialog-prompt">
+ {c("settings.dialogChangeAvatar.prompt.select")}
+ </div>
+ <input
+ className="change-avatar-select-input"
+ type="file"
+ accept="image/*"
+ onChange={onSelectFile}
+ />
+ </div>
+ );
+ } else if (state === "crop") {
+ if (file == null) {
+ throw new UiLogicError();
+ }
+ return (
+ <div className="change-avatar-dialog-container">
+ <ImageCropper
+ {...imageCropperProps}
+ containerClassName="change-avatar-cropper"
+ />
+ <div className="change-avatar-dialog-prompt">
+ {c("settings.dialogChangeAvatar.prompt.crop")}
+ </div>
+ </div>
+ );
+ } else if (state === "process-crop") {
+ return (
+ <div className="change-avatar-dialog-container">
+ <div className="change-avatar-dialog-prompt">
+ {c("settings.dialogChangeAvatar.prompt.processingCrop")}
+ </div>
+ </div>
+ );
+ } else if (state === "preview") {
+ return (
+ <div className="change-avatar-dialog-container">
+ <BlobImage
+ className="change-avatar-preview-image"
+ src={resultBlob}
+ alt={
+ c("settings.dialogChangeAvatar.previewImgAlt") ?? undefined
+ }
+ />
+ <div className="change-avatar-dialog-prompt">
+ {c("settings.dialogChangeAvatar.prompt.preview")}
+ </div>
+ </div>
+ );
+ } else if (state === "uploading") {
+ return (
+ <div className="change-avatar-dialog-container">
+ <BlobImage
+ className="change-avatar-preview-image"
+ src={resultBlob}
+ />
+ <div className="change-avatar-dialog-prompt">
+ {c("settings.dialogChangeAvatar.prompt.uploading")}
+ </div>
+ </div>
+ );
+ } else if (state === "success") {
+ return (
+ <div className="change-avatar-dialog-container">
+ <div className="change-avatar-dialog-prompt success">
+ {c("operationDialog.success")}
+ </div>
+ </div>
+ );
+ } else {
+ return (
+ <div className="change-avatar-dialog-container">
+ <BlobImage
+ className="change-avatar-preview-image"
+ src={resultBlob}
+ />
+ <div className="change-avatar-dialog-prompt error">
+ {c(message)}
+ </div>
+ </div>
+ );
+ }
+ })()}
+ </DialogContainer>
+ </Dialog>
+ );
+}
diff --git a/FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx b/FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx
new file mode 100644
index 00000000..912f554f
--- /dev/null
+++ b/FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx
@@ -0,0 +1,26 @@
+import { getHttpUserClient } from "~src/http/user";
+import { useUserLoggedIn } from "~src/services/user";
+
+import { OperationDialog } from "~src/components/dialog";
+
+export default function ChangeNicknameDialog() {
+ const user = useUserLoggedIn();
+
+ return (
+ <OperationDialog
+ title="settings.dialogChangeNickname.title"
+ inputs={[
+ {
+ key: "newNickname",
+ type: "text",
+ label: "settings.dialogChangeNickname.inputLabel",
+ },
+ ]}
+ onProcess={({ newNickname }) => {
+ return getHttpUserClient().patch(user.username, {
+ nickname: newNickname,
+ });
+ }}
+ />
+ );
+}
diff --git a/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx b/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx
new file mode 100644
index 00000000..c3111ac8
--- /dev/null
+++ b/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx
@@ -0,0 +1,70 @@
+import { useState } from "react";
+import { useNavigate } from "react-router-dom";
+
+import { userService } from "~src/services/user";
+
+import { OperationDialog } from "~src/components/dialog";
+
+export function ChangePasswordDialog() {
+ const navigate = useNavigate();
+
+ const [redirect, setRedirect] = useState<boolean>(false);
+
+ return (
+ <OperationDialog
+ title="settings.dialogChangePassword.title"
+ color="danger"
+ inputPrompt="settings.dialogChangePassword.prompt"
+ inputs={{
+ inputs: [
+ {
+ key: "oldPassword",
+ type: "text",
+ label: "settings.dialogChangePassword.inputOldPassword",
+ password: true,
+ },
+ {
+ key: "newPassword",
+ type: "text",
+ label: "settings.dialogChangePassword.inputNewPassword",
+ password: true,
+ },
+ {
+ key: "retypedNewPassword",
+ type: "text",
+ label: "settings.dialogChangePassword.inputRetypeNewPassword",
+ password: true,
+ },
+ ],
+ validator: (
+ { oldPassword, newPassword, retypedNewPassword },
+ errors,
+ ) => {
+ if (oldPassword === "") {
+ errors["oldPassword"] =
+ "settings.dialogChangePassword.errorEmptyOldPassword";
+ }
+ if (newPassword === "") {
+ errors["newPassword"] =
+ "settings.dialogChangePassword.errorEmptyNewPassword";
+ }
+ if (retypedNewPassword !== newPassword) {
+ errors["retypedNewPassword"] =
+ "settings.dialogChangePassword.errorRetypeNotMatch";
+ }
+ },
+ }}
+ onProcess={async ({ oldPassword, newPassword }) => {
+ await userService.changePassword(oldPassword, newPassword);
+ setRedirect(true);
+ }}
+ onSuccessAndClose={() => {
+ if (redirect) {
+ navigate("/login");
+ }
+ }}
+ />
+ );
+}
+
+export default ChangePasswordDialog;
diff --git a/FrontEnd/src/pages/setting/index.css b/FrontEnd/src/pages/setting/index.css
new file mode 100644
index 00000000..19e7cff4
--- /dev/null
+++ b/FrontEnd/src/pages/setting/index.css
@@ -0,0 +1,76 @@
+.setting-section {
+ padding: 1em 0;
+ margin: 1em 0;
+}
+
+.setting-section-title {
+ padding: 0 1em;
+}
+
+.setting-section-item-area {
+ margin-top: 1em;
+ border-top: 1px solid var(--cru-primary-color);
+}
+
+.setting-item-container {
+ padding: 0.5em 1em;
+ transition: background-color 0.3s;
+ background-color: var(--cru-clickable-grayscale-normal-color);
+ border-bottom: 1px solid var(--cru-clickable-grayscale-active-color);
+ display: flex;
+ align-items: center;
+}
+
+.setting-item-container:hover {
+ background-color: var(--cru-clickable-grayscale-hover-color);
+}
+
+.setting-item-container:focus {
+ background-color: var(--cru-clickable-grayscale-focus-color);
+}
+
+.setting-item-container:active {
+ background-color: var(--cru-clickable-grayscale-active-color);
+}
+
+.setting-item-container.danger {
+ color: var(--cru-danger-color);
+}
+
+.setting-item-label-sub {
+ color: var(--cru-text-minor-color);
+}
+
+.setting-item-value-area {
+ margin-left: auto;
+}
+
+.setting-item-container.setting-type-button {
+ cursor: pointer;
+}
+
+.register-code {
+ background: var(--cru-text-major-color);
+ color: var(--cru-background-color);
+ border-radius: 3px;
+ padding: 0.2em;
+ cursor: pointer;
+}
+
+@media (max-width: 576) {
+ .setting-item-container.setting-type-select {
+ flex-direction: column;
+ }
+
+ .setting-item-container.setting-type-select .setting-item-value-area {
+ margin-top: 1em;
+ }
+
+ .register-code-setting-item {
+ flex-direction: column;
+ }
+
+ .register-code-setting-item .register-code {
+ margin-top: 1em;
+ }
+} \ No newline at end of file
diff --git a/FrontEnd/src/pages/setting/index.tsx b/FrontEnd/src/pages/setting/index.tsx
new file mode 100644
index 00000000..88ab5cb2
--- /dev/null
+++ b/FrontEnd/src/pages/setting/index.tsx
@@ -0,0 +1,297 @@
+import {
+ useState,
+ useEffect,
+ ReactNode,
+ ComponentPropsWithoutRef,
+} from "react";
+import { useTranslation } from "react-i18next"; // For change language.
+import { useNavigate } from "react-router-dom";
+import classNames from "classnames";
+
+import { useUser, userService } from "~src/services/user";
+import { getHttpUserClient } from "~src/http/user";
+
+import { useC, Text } from "~src/common";
+
+import { pushAlert } from "~src/components/alert";
+import {
+ useDialog,
+ DialogProvider,
+ ConfirmDialog,
+} from "~src/components/dialog";
+import Card from "~src/components/Card";
+import Spinner from "~src/components/Spinner";
+import Page from "~src/components/Page";
+
+import ChangePasswordDialog from "./ChangePasswordDialog";
+import ChangeAvatarDialog from "./ChangeAvatarDialog";
+import ChangeNicknameDialog from "./ChangeNicknameDialog";
+
+import "./index.css";
+
+interface SettingSectionProps
+ extends Omit<ComponentPropsWithoutRef<typeof Card>, "title"> {
+ title: Text;
+ children?: ReactNode;
+}
+
+function SettingSection({
+ title,
+ className,
+ children,
+ ...otherProps
+}: SettingSectionProps) {
+ const c = useC();
+
+ return (
+ <Card className={classNames(className, "setting-section")} {...otherProps}>
+ <h2 className="setting-section-title">{c(title)}</h2>
+ <div className="setting-section-item-area">{children}</div>
+ </Card>
+ );
+}
+
+interface SettingItemContainerProps
+ extends Omit<ComponentPropsWithoutRef<"div">, "title"> {
+ title: Text;
+ description?: Text;
+ danger?: boolean;
+ extraClassName?: string;
+}
+
+function SettingItemContainer({
+ title,
+ description,
+ danger,
+ extraClassName,
+ className,
+ children,
+ ...otherProps
+}: SettingItemContainerProps) {
+ const c = useC();
+
+ return (
+ <div
+ className={classNames(
+ className,
+ "setting-item-container",
+ danger && "danger",
+ extraClassName,
+ )}
+ {...otherProps}
+ >
+ <div className="setting-item-label-area">
+ <div className="setting-item-label-title">{c(title)}</div>
+ <small className="setting-item-label-sub">{c(description)}</small>
+ </div>
+ <div className="setting-item-value-area">{children}</div>
+ </div>
+ );
+}
+
+type ButtonSettingItemProps = Omit<SettingItemContainerProps, "extraClassName">;
+
+function ButtonSettingItem(props: ButtonSettingItemProps) {
+ return (
+ <SettingItemContainer extraClassName="setting-type-button" {...props} />
+ );
+}
+
+interface SelectSettingItemProps
+ extends Omit<SettingItemContainerProps, "onSelect" | "extraClassName"> {
+ options: {
+ value: string;
+ label: Text;
+ }[];
+ value?: string | null;
+ onSelect: (value: string) => void;
+}
+
+function SelectSettingsItem({
+ options,
+ value,
+ onSelect,
+ ...extraProps
+}: SelectSettingItemProps) {
+ const c = useC();
+
+ return (
+ <SettingItemContainer extraClassName="setting-type-select" {...extraProps}>
+ {value == null ? (
+ <Spinner />
+ ) : (
+ <select
+ className="select-setting-item-select"
+ value={value}
+ onChange={(e) => {
+ onSelect(e.target.value);
+ }}
+ >
+ {options.map(({ value, label }) => (
+ <option key={value} value={value}>
+ {c(label)}
+ </option>
+ ))}
+ </select>
+ )}
+ </SettingItemContainer>
+ );
+}
+
+function RegisterCodeSettingItem() {
+ const user = useUser();
+
+ // undefined: loading
+ const [registerCode, setRegisterCode] = useState<undefined | null | string>();
+
+ const { controller, createDialogSwitch } = useDialog({
+ confirm: (
+ <ConfirmDialog
+ title="settings.renewRegisterCode"
+ body="settings.renewRegisterCodeDesc"
+ onConfirm={() => {
+ if (user == null) throw new Error();
+ void getHttpUserClient()
+ .renewRegisterCode(user.username)
+ .then(() => {
+ setRegisterCode(undefined);
+ });
+ }}
+ />
+ ),
+ });
+
+ useEffect(() => {
+ setRegisterCode(undefined);
+ }, [user]);
+
+ useEffect(() => {
+ if (user != null && registerCode === undefined) {
+ void getHttpUserClient()
+ .getRegisterCode(user.username)
+ .then((code) => {
+ setRegisterCode(code.registerCode ?? null);
+ });
+ }
+ }, [user, registerCode]);
+
+ return (
+ <>
+ <SettingItemContainer
+ title="settings.myRegisterCode"
+ description="settings.myRegisterCodeDesc"
+ className="register-code-setting-item"
+ onClick={createDialogSwitch("confirm")}
+ >
+ {registerCode === undefined ? (
+ <Spinner />
+ ) : registerCode === null ? (
+ <span>Noop</span>
+ ) : (
+ <code
+ className="register-code"
+ onClick={(event) => {
+ void navigator.clipboard.writeText(registerCode).then(() => {
+ pushAlert({
+ color: "create",
+ message: "settings.myRegisterCodeCopied",
+ });
+ });
+ event.stopPropagation();
+ }}
+ >
+ {registerCode}
+ </code>
+ )}
+ </SettingItemContainer>
+ <DialogProvider controller={controller} />
+ </>
+ );
+}
+
+function LanguageChangeSettingItem() {
+ const { i18n } = useTranslation();
+
+ const language = i18n.language.slice(0, 2);
+
+ return (
+ <SelectSettingsItem
+ title="settings.languagePrimary"
+ description="settings.languageSecondary"
+ options={[
+ {
+ value: "zh",
+ label: {
+ type: "custom",
+ value: "中文",
+ },
+ },
+ {
+ value: "en",
+ label: {
+ type: "custom",
+ value: "English",
+ },
+ },
+ ]}
+ value={language}
+ onSelect={(value) => {
+ void i18n.changeLanguage(value);
+ }}
+ />
+ );
+}
+
+export default function SettingPage() {
+ const user = useUser();
+ const navigate = useNavigate();
+
+ const { controller, createDialogSwitch } = useDialog({
+ "change-nickname": <ChangeNicknameDialog />,
+ "change-avatar": <ChangeAvatarDialog />,
+ "change-password": <ChangePasswordDialog />,
+ logout: (
+ <ConfirmDialog
+ title="settings.dialogConfirmLogout.title"
+ body="settings.dialogConfirmLogout.prompt"
+ onConfirm={() => {
+ void userService.logout().then(() => {
+ navigate("/");
+ });
+ }}
+ />
+ ),
+ });
+
+ return (
+ <Page noTopPadding>
+ {user ? (
+ <SettingSection title="settings.subheader.account">
+ <RegisterCodeSettingItem />
+ <ButtonSettingItem
+ title="settings.changeAvatar"
+ onClick={createDialogSwitch("change-avatar")}
+ />
+ <ButtonSettingItem
+ title="settings.changeNickname"
+ onClick={createDialogSwitch("change-nickname")}
+ />
+ <ButtonSettingItem
+ title="settings.changePassword"
+ onClick={createDialogSwitch("change-password")}
+ danger
+ />
+ <ButtonSettingItem
+ title="settings.logout"
+ onClick={createDialogSwitch("logout")}
+ danger
+ />
+ </SettingSection>
+ ) : null}
+ <SettingSection title="settings.subheader.customization">
+ <LanguageChangeSettingItem />
+ </SettingSection>
+ <DialogProvider controller={controller} />
+ </Page>
+ );
+}
diff --git a/FrontEnd/src/pages/timeline/ConnectionStatusBadge.css b/FrontEnd/src/pages/timeline/ConnectionStatusBadge.css
new file mode 100644
index 00000000..0a6979cb
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/ConnectionStatusBadge.css
@@ -0,0 +1,38 @@
+.connection-status-badge {
+ font-size: 0.8em;
+ border-radius: 5px;
+ padding: 0.1em 1em;
+}
+
+.connection-status-badge::before {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ display: inline-block;
+ content: "";
+ margin-right: 0.6em;
+}
+
+.connection-status-badge.success {
+ color: var(--cru-create-color);
+}
+
+.connection-status-badge.success::before {
+ background-color: var(--cru-create-color);
+}
+
+.connection-status-badge.warning {
+ color: var(--cru-warn-color);
+}
+
+.connection-status-badge.warning::before {
+ background-color: var(--cru-warn-color);
+}
+
+.connection-status-badge.danger {
+ color: var(--cru-danger-color);
+}
+
+.connection-status-badge.danger::before {
+ background-color: var(--cru-danger-color);
+}
diff --git a/FrontEnd/src/pages/timeline/ConnectionStatusBadge.tsx b/FrontEnd/src/pages/timeline/ConnectionStatusBadge.tsx
new file mode 100644
index 00000000..63990878
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/ConnectionStatusBadge.tsx
@@ -0,0 +1,36 @@
+import classNames from "classnames";
+import { HubConnectionState } from "@microsoft/signalr";
+
+import { useC }from '~/src/components/common';
+
+import "./ConnectionStatusBadge.css";
+
+interface ConnectionStatusBadgeProps {
+ status: HubConnectionState;
+ className?: string;
+}
+
+const classNameMap: Record<HubConnectionState, string> = {
+ Connected: "success",
+ Connecting: "warning",
+ Disconnected: "danger",
+ Disconnecting: "warning",
+ Reconnecting: "warning",
+};
+
+export default function ConnectionStatusBadge({status, className}: ConnectionStatusBadgeProps) {
+ const c = useC();
+
+ return (
+ <div
+ className={classNames(
+ "connection-status-badge",
+ classNameMap[status],
+ className
+ )}
+ >
+ {c(`connectionState.${status}`)}
+ </div>
+ );
+};
+
diff --git a/FrontEnd/src/pages/timeline/Timeline.css b/FrontEnd/src/pages/timeline/Timeline.css
new file mode 100644
index 00000000..db25eda0
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/Timeline.css
@@ -0,0 +1,42 @@
+.timeline-container {
+ --timeline-background-color: hsl(0, 0%, 95%);
+ --timeline-shadow-color: hsla(0, 0%, 0%, 0.5);
+ --timeline-card-shadow: 2px 1px 10px -2px var(--timeline-shadow-color);
+ --timeline-post-card-background-color: hsl(0, 0%, 100%);
+ --timeline-post-card-shadow: 0px 0px 11px -2px var(--timeline-shadow-color);
+ --timeline-post-card-border-radius: 10px;
+ --timeline-post-text-color: hsl(0, 0%, 0%);
+ --timeline-datetime-label-background-color: hsl(0, 0%, 30%);
+}
+
+@media (prefers-color-scheme: dark) {
+ .timeline-container {
+ --timeline-background-color: hsl(0, 0%, 0%);
+ --timeline-post-card-background-color: hsl(0, 0%, 15%);
+ --timeline-post-card-shadow: none;
+ }
+}
+
+.timeline {
+ z-index: 0;
+ position: relative;
+ width: 100%;
+ padding-top: 10px;
+ background: var(--timeline-background-color);
+}
+
+.timeline-sync-state-badge {
+ font-size: 0.8em;
+ padding: 3px 8px;
+ border-radius: 5px;
+ background: #e8fbff;
+}
+
+.timeline-sync-state-badge-pin {
+ display: inline-block;
+ width: 0.4em;
+ height: 0.4em;
+ border-radius: 50%;
+ vertical-align: middle;
+ margin-right: 0.6em;
+}
diff --git a/FrontEnd/src/pages/timeline/Timeline.tsx b/FrontEnd/src/pages/timeline/Timeline.tsx
new file mode 100644
index 00000000..32cbf8c8
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/Timeline.tsx
@@ -0,0 +1,180 @@
+import { useState, useEffect } from "react";
+import classNames from "classnames";
+import { HubConnectionState } from "@microsoft/signalr";
+
+import {
+ HttpForbiddenError,
+ HttpNetworkError,
+ HttpNotFoundError,
+} from "~src/http/common";
+import {
+ getHttpTimelineClient,
+ HttpTimelineInfo,
+ HttpTimelinePostInfo,
+} from "~src/http/timeline";
+
+import { getTimelinePostUpdate$ } from "~src/services/timeline";
+
+import { useScrollToBottom } from "~src/components/hooks";
+
+import TimelinePostList from "./TimelinePostList";
+import TimelineInfoCard from "./TimelineInfoCard";
+import TimelinePostEdit from "./edit/TimelinePostCreateView";
+
+import "./Timeline.css";
+
+export interface TimelineProps {
+ className?: string;
+ timelineOwner: string;
+ timelineName: string;
+}
+
+export function Timeline(props: TimelineProps) {
+ const { timelineOwner, timelineName, className } = props;
+
+ const [timeline, setTimeline] = useState<HttpTimelineInfo | null>(null);
+ const [posts, setPosts] = useState<HttpTimelinePostInfo[] | null>(null);
+ const [signalrState, setSignalrState] = useState<HubConnectionState>(
+ HubConnectionState.Connecting,
+ );
+ const [error, setError] = useState<
+ "offline" | "forbid" | "notfound" | "error" | null
+ >(null);
+
+ const [currentPage, setCurrentPage] = useState(1);
+ const [totalPage, setTotalPage] = useState(0);
+
+ const [timelineReloadKey, setTimelineReloadKey] = useState(0);
+ const [postsReloadKey, setPostsReloadKey] = useState(0);
+
+ const updateTimeline = (): void => setTimelineReloadKey((o) => o + 1);
+ const updatePosts = (): void => setPostsReloadKey((o) => o + 1);
+
+ useEffect(() => {
+ setTimeline(null);
+ setPosts(null);
+ setError(null);
+ setSignalrState(HubConnectionState.Connecting);
+ }, [timelineOwner, timelineName]);
+
+ useEffect(() => {
+ getHttpTimelineClient()
+ .getTimeline(timelineOwner, timelineName)
+ .then(
+ (t) => {
+ setTimeline(t);
+ },
+ (error) => {
+ if (error instanceof HttpNetworkError) {
+ setError("offline");
+ } else if (error instanceof HttpForbiddenError) {
+ setError("forbid");
+ } else if (error instanceof HttpNotFoundError) {
+ setError("notfound");
+ } else {
+ console.error(error);
+ setError("error");
+ }
+ },
+ );
+ }, [timelineOwner, timelineName, timelineReloadKey]);
+
+ useEffect(() => {
+ getHttpTimelineClient()
+ .listPost(timelineOwner, timelineName, 1)
+ .then(
+ (page) => {
+ setPosts(
+ page.items.filter((p): p is HttpTimelinePostInfo => !p.deleted),
+ );
+ setTotalPage(page.totalPageCount);
+ },
+ (error) => {
+ if (error instanceof HttpNetworkError) {
+ setError("offline");
+ } else if (error instanceof HttpForbiddenError) {
+ setError("forbid");
+ } else if (error instanceof HttpNotFoundError) {
+ setError("notfound");
+ } else {
+ console.error(error);
+ setError("error");
+ }
+ },
+ );
+ }, [timelineOwner, timelineName, postsReloadKey]);
+
+ useEffect(() => {
+ const timelinePostUpdate$ = getTimelinePostUpdate$(
+ timelineOwner,
+ timelineName,
+ );
+ const subscription = timelinePostUpdate$.subscribe(({ update, state }) => {
+ if (update) {
+ setPostsReloadKey((o) => o + 1);
+ }
+ setSignalrState(state);
+ });
+ return () => {
+ subscription.unsubscribe();
+ };
+ }, [timelineOwner, timelineName]);
+
+ useScrollToBottom(() => {
+ console.log(`Load page ${currentPage + 1}.`);
+ setCurrentPage(currentPage + 1);
+ void getHttpTimelineClient()
+ .listPost(timelineOwner, timelineName, currentPage + 1)
+ .then(
+ (page) => {
+ const ps = page.items.filter(
+ (p): p is HttpTimelinePostInfo => !p.deleted,
+ );
+ setPosts((old) => [...(old ?? []), ...ps]);
+ },
+ (error) => {
+ if (error instanceof HttpNetworkError) {
+ setError("offline");
+ } else if (error instanceof HttpForbiddenError) {
+ setError("forbid");
+ } else if (error instanceof HttpNotFoundError) {
+ setError("notfound");
+ } else {
+ console.error(error);
+ setError("error");
+ }
+ },
+ );
+ }, currentPage < totalPage);
+
+ if (error === "offline") {
+ return <div className={className}>Offline.</div>;
+ } else if (error === "notfound") {
+ return <div className={className}>Not exist.</div>;
+ } else if (error === "forbid") {
+ return <div className={className}>Forbid.</div>;
+ } else if (error === "error") {
+ return <div className={className}>Error.</div>;
+ }
+ return (
+ <div className="timeline-container">
+ {timeline && (
+ <TimelineInfoCard
+ timeline={timeline}
+ connectionStatus={signalrState}
+ onReload={updateTimeline}
+ />
+ )}
+ {posts && (
+ <div className={classNames("timeline", className)}>
+ {timeline?.postable && (
+ <TimelinePostEdit timeline={timeline} onPosted={updatePosts} />
+ )}
+ <TimelinePostList posts={posts} onReload={updatePosts} />
+ </div>
+ )}
+ </div>
+ );
+}
+
+export default Timeline;
diff --git a/FrontEnd/src/pages/timeline/TimelineDateLabel.css b/FrontEnd/src/pages/timeline/TimelineDateLabel.css
new file mode 100644
index 00000000..47a4cb44
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelineDateLabel.css
@@ -0,0 +1,9 @@
+.timeline-post-date-badge {
+ display: inline-block;
+ padding: 0.2em 0.5em;
+ border-radius: 0.4em;
+ background: var(--timeline-datetime-label-background-color);
+ color: white;
+ font-size: 0.8em;
+ margin-left: 5em;
+}
diff --git a/FrontEnd/src/pages/timeline/TimelineDateLabel.tsx b/FrontEnd/src/pages/timeline/TimelineDateLabel.tsx
new file mode 100644
index 00000000..eaadcc1a
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelineDateLabel.tsx
@@ -0,0 +1,13 @@
+import TimelinePostContainer from "./TimelinePostContainer";
+
+import "./TimelineDateLabel.css";
+
+export default function TimelineDateLabel({ date }: { date: Date }) {
+ return (
+ <TimelinePostContainer>
+ <div className="timeline-post-date-badge">
+ {date.toLocaleDateString()}
+ </div>
+ </TimelinePostContainer>
+ );
+}
diff --git a/FrontEnd/src/pages/timeline/TimelineDeleteDialog.tsx b/FrontEnd/src/pages/timeline/TimelineDeleteDialog.tsx
new file mode 100644
index 00000000..d1af364b
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelineDeleteDialog.tsx
@@ -0,0 +1,54 @@
+import { useNavigate } from "react-router-dom";
+import { Trans } from "react-i18next";
+
+import { getHttpTimelineClient, HttpTimelineInfo } from "~src/http/timeline";
+
+import { OperationDialog } from "~src/components/dialog";
+
+interface TimelineDeleteDialog {
+ timeline: HttpTimelineInfo;
+}
+
+export default function TimelineDeleteDialog({ timeline }: TimelineDeleteDialog) {
+ const navigate = useNavigate();
+
+ return (
+ <OperationDialog
+ title="timeline.deleteDialog.title"
+ color="danger"
+ inputPromptNode={
+ <Trans
+ i18nKey="timeline.deleteDialog.inputPrompt"
+ values={{ name: timeline.nameV2 }}
+ >
+ 0<code>1</code>2
+ </Trans>
+ }
+ inputs={{
+ inputs: [
+ {
+ key: "name",
+ type: "text",
+ label: "",
+ },
+ ],
+ validator: ({ name }, errors) => {
+ if (name !== timeline.nameV2) {
+ errors.name = "timeline.deleteDialog.notMatch";
+ }
+ },
+ }}
+ onProcess={() => {
+ return getHttpTimelineClient().deleteTimeline(
+ timeline.owner.username,
+ timeline.nameV2,
+ );
+ }}
+ onSuccessAndClose={() => {
+ navigate("/", { replace: true });
+ }}
+ />
+ );
+};
+
+TimelineDeleteDialog;
diff --git a/FrontEnd/src/pages/timeline/TimelineInfoCard.css b/FrontEnd/src/pages/timeline/TimelineInfoCard.css
new file mode 100644
index 00000000..afcb6409
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelineInfoCard.css
@@ -0,0 +1,63 @@
+.timeline-card {
+ position: fixed;
+ z-index: 1029;
+ top: 56px;
+ right: 0;
+ margin: 0.5em;
+ padding: 0.5em;
+ box-shadow: var(--timeline-card-shadow);
+}
+
+@media (min-width: 576px) {
+ .timeline-card-expand {
+ min-width: 400px;
+ }
+}
+
+.timeline-card-title {
+ display: inline-block;
+ vertical-align: middle;
+ color: var(--cru-text-major-color);
+ margin: 0.5em 1em;
+}
+
+.timeline-card-title-name {
+ margin-inline-start: 1em;
+ color: var(--cru-text-minor-color);
+}
+
+.timeline-card-user {
+ display: flex;
+ align-items: center;
+ margin: 0 1em 0.5em;
+}
+
+.timeline-card-user-avatar {
+ width: 2em;
+ height: 2em;
+ border-radius: 50%;
+}
+
+.timeline-card-user-nickname {
+ margin-inline: 0.6em;
+}
+
+.timeline-card-description {
+ margin: 0 1em 0.5em;
+}
+
+.timeline-card-top-right-area {
+ float: right;
+ display: flex;
+ align-items: center;
+ margin: 0 1em;
+}
+
+.timeline-card-buttons {
+ display: flex;
+ justify-content: end;
+}
+
+.timeline-card-button {
+ margin: 0 0.2em;
+}
diff --git a/FrontEnd/src/pages/timeline/TimelineInfoCard.tsx b/FrontEnd/src/pages/timeline/TimelineInfoCard.tsx
new file mode 100644
index 00000000..2bc40877
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelineInfoCard.tsx
@@ -0,0 +1,208 @@
+import { useState } from "react";
+import { HubConnectionState } from "@microsoft/signalr";
+
+import { useUser } from "~src/services/user";
+
+import { HttpTimelineInfo } from "~src/http/timeline";
+import { getHttpBookmarkClient } from "~src/http/bookmark";
+
+import { pushAlert } from "~src/components/alert";
+import { useMobile } from "~src/components/hooks";
+import { IconButton } from "~src/components/button";
+import {
+ Dialog,
+ FullPageDialog,
+ DialogProvider,
+ useDialog,
+} from "~src/components/dialog";
+import UserAvatar from "~src/components/user/UserAvatar";
+import PopupMenu from "~src/components/menu/PopupMenu";
+import Card from "~src/components/Card";
+
+import TimelineDeleteDialog from "./TimelineDeleteDialog";
+import ConnectionStatusBadge from "./ConnectionStatusBadge";
+import TimelineMember from "./TimelineMember";
+import TimelinePropertyChangeDialog from "./TimelinePropertyChangeDialog";
+
+import "./TimelineInfoCard.css";
+
+function CollapseButton({
+ collapse,
+ onClick,
+ className,
+}: {
+ collapse: boolean;
+ onClick: () => void;
+ className?: string;
+}) {
+ return (
+ <IconButton
+ color="primary"
+ icon={collapse ? "info-circle" : "x-circle"}
+ onClick={onClick}
+ className={className}
+ />
+ );
+}
+
+interface TimelineInfoCardProps {
+ timeline: HttpTimelineInfo;
+ connectionStatus: HubConnectionState;
+ onReload: () => void;
+}
+
+function TimelineInfoContent({
+ timeline,
+ onReload,
+}: Omit<TimelineInfoCardProps, "connectionStatus">) {
+ const user = useUser();
+
+ const { controller, createDialogSwitch } = useDialog({
+ member: (
+ <Dialog>
+ <TimelineMember timeline={timeline} onChange={onReload} />
+ </Dialog>
+ ),
+ property: (
+ <TimelinePropertyChangeDialog timeline={timeline} onChange={onReload} />
+ ),
+ delete: <TimelineDeleteDialog timeline={timeline} />,
+ });
+
+ return (
+ <div>
+ <h3 className="timeline-card-title">
+ {timeline.title}
+ <small className="timeline-card-title-name">{timeline.nameV2}</small>
+ </h3>
+ <div className="timeline-card-user">
+ <UserAvatar
+ username={timeline.owner.username}
+ className="timeline-card-user-avatar"
+ />
+ <span className="timeline-card-user-nickname">
+ {timeline.owner.nickname}
+ </span>
+ <small className="timeline-card-user-username">
+ @{timeline.owner.username}
+ </small>
+ </div>
+ <p className="timeline-card-description">{timeline.description}</p>
+ <div className="timeline-card-buttons">
+ {user && (
+ <IconButton
+ icon={timeline.isBookmark ? "bookmark-fill" : "bookmark"}
+ color="primary"
+ className="timeline-card-button"
+ onClick={() => {
+ getHttpBookmarkClient()
+ [timeline.isBookmark ? "delete" : "post"](
+ user.username,
+ timeline.owner.username,
+ timeline.nameV2,
+ )
+ .then(onReload, () => {
+ pushAlert({
+ message: timeline.isBookmark
+ ? "timeline.removeBookmarkFail"
+ : "timeline.addBookmarkFail",
+ color: "danger",
+ });
+ });
+ }}
+ />
+ )}
+ <IconButton
+ icon="people"
+ color="primary"
+ className="timeline-card-button"
+ onClick={createDialogSwitch("member")}
+ />
+ {timeline.manageable && (
+ <PopupMenu
+ items={[
+ {
+ type: "button",
+ text: "timeline.manageItem.property",
+ onClick: createDialogSwitch("property"),
+ },
+ { type: "divider" },
+ {
+ type: "button",
+ onClick: createDialogSwitch("delete"),
+ color: "danger",
+ text: "timeline.manageItem.delete",
+ },
+ ]}
+ containerClassName="d-inline"
+ >
+ <IconButton
+ color="primary"
+ className="timeline-card-button"
+ icon="three-dots-vertical"
+ />
+ </PopupMenu>
+ )}
+ </div>
+ <DialogProvider controller={controller} />
+ </div>
+ );
+}
+
+export default function TimelineInfoCard(props: TimelineInfoCardProps) {
+ const { timeline, connectionStatus, onReload } = props;
+
+ const [collapse, setCollapse] = useState(true);
+
+ const isMobile = useMobile((mobile) => {
+ if (!mobile) {
+ switchDialog(null);
+ } else {
+ setCollapse(true);
+ }
+ });
+
+ const { controller, switchDialog } = useDialog(
+ {
+ "full-page": (
+ <FullPageDialog>
+ <TimelineInfoContent timeline={timeline} onReload={onReload} />
+ </FullPageDialog>
+ ),
+ },
+ {
+ onClose: {
+ "full-page": () => {
+ setCollapse(true);
+ },
+ },
+ },
+ );
+
+ return (
+ <Card
+ color="secondary"
+ className={`timeline-card timeline-card-${
+ collapse ? "collapse" : "expand"
+ }`}
+ >
+ <div className="timeline-card-top-right-area">
+ <ConnectionStatusBadge status={connectionStatus} />
+ <CollapseButton
+ collapse={collapse}
+ onClick={() => {
+ const open = collapse;
+ setCollapse(!open);
+ if (isMobile && open) {
+ switchDialog("full-page");
+ }
+ }}
+ />
+ </div>
+ {!collapse && !isMobile && (
+ <TimelineInfoContent timeline={timeline} onReload={onReload} />
+ )}
+ <DialogProvider controller={controller} />
+ </Card>
+ );
+}
diff --git a/FrontEnd/src/pages/timeline/TimelineMember.css b/FrontEnd/src/pages/timeline/TimelineMember.css
new file mode 100644
index 00000000..3ad74c57
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelineMember.css
@@ -0,0 +1,20 @@
+.timeline-member-item {
+ align-items: center;
+ display: flex;
+ padding: 0.6em;
+}
+
+.timeline-member-avatar {
+ height: 50px;
+ width: 50px;
+ border-radius: 50%;
+}
+
+.timeline-member-info {
+ margin-left: 1em;
+ margin-right: auto;
+}
+
+.timeline-member-user-search {
+ margin-top: 1em;
+}
diff --git a/FrontEnd/src/pages/timeline/TimelineMember.tsx b/FrontEnd/src/pages/timeline/TimelineMember.tsx
new file mode 100644
index 00000000..0812016f
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelineMember.tsx
@@ -0,0 +1,189 @@
+import { useState } from "react";
+import { useTranslation } from "react-i18next";
+
+import { convertI18nText, I18nText } from "~src/common";
+
+import { HttpUser } from "~src/http/user";
+import { getHttpSearchClient } from "~src/http/search";
+import { getHttpTimelineClient, HttpTimelineInfo } from "~src/http/timeline";
+
+import SearchInput from "~src/components/SearchInput";
+import UserAvatar from "~src/components/user/UserAvatar";
+import { IconButton } from "~src/components/button";
+import { ListContainer, ListItemContainer } from "~src/components/list";
+
+import "./TimelineMember.css";
+
+function TimelineMemberItem({
+ user,
+ add,
+ onAction,
+}: {
+ user: HttpUser;
+ add?: boolean;
+ onAction?: (username: string) => void;
+}) {
+ return (
+ <ListItemContainer className="timeline-member-item">
+ <UserAvatar username={user.username} className="timeline-member-avatar" />
+ <div className="timeline-member-info">
+ <div className="timeline-member-nickname">{user.nickname}</div>
+ <small className="timeline-member-username">
+ {"@" + user.username}
+ </small>
+ </div>
+ {onAction ? (
+ <div className="timeline-member-action">
+ <IconButton
+ icon={add ? "plus-lg" : "trash"}
+ color={add ? "create" : "danger"}
+ onClick={() => {
+ onAction(user.username);
+ }}
+ />
+ </div>
+ ) : null}
+ </ListItemContainer>
+ );
+}
+
+function TimelineMemberUserSearch({
+ timeline,
+ onChange,
+}: {
+ timeline: HttpTimelineInfo;
+ onChange: () => void;
+}) {
+ const { t } = useTranslation();
+
+ const [userSearchText, setUserSearchText] = useState<string>("");
+ const [userSearchState, setUserSearchState] = useState<
+ | {
+ type: "users";
+ data: HttpUser[];
+ }
+ | { type: "error"; data: I18nText }
+ | { type: "loading" }
+ | { type: "init" }
+ >({ type: "init" });
+
+ return (
+ <div className="timeline-member-user-search">
+ <SearchInput
+ className=""
+ value={userSearchText}
+ onChange={(v) => {
+ setUserSearchText(v);
+ }}
+ loading={userSearchState.type === "loading"}
+ onButtonClick={() => {
+ if (userSearchText === "") {
+ setUserSearchState({
+ type: "error",
+ data: "login.emptyUsername",
+ });
+ return;
+ }
+ setUserSearchState({ type: "loading" });
+ getHttpSearchClient()
+ .searchUsers(userSearchText)
+ .then(
+ (users) => {
+ users = users.filter(
+ (user) =>
+ timeline.members.findIndex(
+ (m) => m.username === user.username,
+ ) === -1 && timeline.owner.username !== user.username,
+ );
+ setUserSearchState({ type: "users", data: users });
+ },
+ (e) => {
+ setUserSearchState({
+ type: "error",
+ data: { type: "custom", value: String(e) },
+ });
+ },
+ );
+ }}
+ />
+ {(() => {
+ if (userSearchState.type === "users") {
+ const users = userSearchState.data;
+ if (users.length === 0) {
+ return <div>{t("timeline.member.noUserAvailableToAdd")}</div>;
+ } else {
+ return (
+ <div className="">
+ {users.map((user) => (
+ <TimelineMemberItem
+ key={user.username}
+ user={user}
+ add
+ onAction={() => {
+ void getHttpTimelineClient()
+ .memberPut(
+ timeline.owner.username,
+ timeline.nameV2,
+ user.username,
+ )
+ .then(() => {
+ setUserSearchText("");
+ setUserSearchState({ type: "init" });
+ onChange();
+ });
+ }}
+ />
+ ))}
+ </div>
+ );
+ }
+ } else if (userSearchState.type === "error") {
+ return (
+ <div className="cru-color-danger">
+ {convertI18nText(userSearchState.data, t)}
+ </div>
+ );
+ }
+ })()}
+ </div>
+ );
+}
+
+interface TimelineMemberProps {
+ timeline: HttpTimelineInfo;
+ onChange: () => void;
+}
+
+export default function TimelineMember(props: TimelineMemberProps) {
+ const { timeline, onChange } = props;
+ const members = [timeline.owner, ...timeline.members];
+
+ return (
+ <div className="container px-4 py-3">
+ <ListContainer>
+ {members.map((member, index) => (
+ <TimelineMemberItem
+ key={member.username}
+ user={member}
+ onAction={
+ timeline.manageable && index !== 0
+ ? () => {
+ void getHttpTimelineClient()
+ .memberDelete(
+ timeline.owner.username,
+ timeline.nameV2,
+ member.username,
+ )
+ .then(onChange);
+ }
+ : undefined
+ }
+ />
+ ))}
+ </ListContainer>
+ {timeline.manageable ? (
+ <TimelineMemberUserSearch timeline={timeline} onChange={onChange} />
+ ) : null}
+ </div>
+ );
+}
diff --git a/FrontEnd/src/pages/timeline/TimelinePostCard.css b/FrontEnd/src/pages/timeline/TimelinePostCard.css
new file mode 100644
index 00000000..f60610c0
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelinePostCard.css
@@ -0,0 +1,9 @@
+.timeline-post-card {
+ padding: 1em 1em 1em 3em;
+ background-color: var(--timeline-post-card-background-color);
+ box-shadow: var(--timeline-post-card-shadow);
+ border-radius: var(--timeline-post-card-border-radius);
+ border: none;
+ position: relative;
+ z-index: 1;
+} \ No newline at end of file
diff --git a/FrontEnd/src/pages/timeline/TimelinePostCard.tsx b/FrontEnd/src/pages/timeline/TimelinePostCard.tsx
new file mode 100644
index 00000000..d3fd3215
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelinePostCard.tsx
@@ -0,0 +1,22 @@
+import { ReactNode } from "react";
+import classNames from "classnames";
+
+import Card from "~src/components/Card";
+
+import "./TimelinePostCard.css";
+
+interface TimelinePostCardProps {
+ className?: string;
+ children?: ReactNode;
+}
+
+export default function TimelinePostCard({
+ className,
+ children,
+}: TimelinePostCardProps) {
+ return (
+ <Card color="primary" className={classNames("timeline-post-card", className)}>
+ {children}
+ </Card>
+ );
+}
diff --git a/FrontEnd/src/pages/timeline/TimelinePostContainer.css b/FrontEnd/src/pages/timeline/TimelinePostContainer.css
new file mode 100644
index 00000000..a12f70b1
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelinePostContainer.css
@@ -0,0 +1,3 @@
+.timeline-post-container {
+ padding: 0.5em 1em;
+} \ No newline at end of file
diff --git a/FrontEnd/src/pages/timeline/TimelinePostContainer.tsx b/FrontEnd/src/pages/timeline/TimelinePostContainer.tsx
new file mode 100644
index 00000000..9dc211b2
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelinePostContainer.tsx
@@ -0,0 +1,20 @@
+import { ReactNode } from "react";
+import classNames from "classnames";
+
+import "./TimelinePostContainer.css";
+
+interface TimelinePostContainerProps {
+ className?: string;
+ children?: ReactNode;
+}
+
+export default function TimelinePostContainer({
+ className,
+ children,
+}: TimelinePostContainerProps) {
+ return (
+ <div className={classNames("timeline-post-container", className)}>
+ {children}
+ </div>
+ );
+}
diff --git a/FrontEnd/src/pages/timeline/TimelinePostList.css b/FrontEnd/src/pages/timeline/TimelinePostList.css
new file mode 100644
index 00000000..bd575554
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelinePostList.css
@@ -0,0 +1,10 @@
+.timeline-post-timeline {
+ position: absolute;
+ left: 2.5em;
+ width: 1em;
+ top: 0;
+ bottom: 0;
+ background-color: var(--timeline-post-line-color);
+ box-shadow: var(--timeline-post-line-shadow);
+ z-index: -1;
+} \ No newline at end of file
diff --git a/FrontEnd/src/pages/timeline/TimelinePostList.tsx b/FrontEnd/src/pages/timeline/TimelinePostList.tsx
new file mode 100644
index 00000000..66262ccd
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelinePostList.tsx
@@ -0,0 +1,75 @@
+import { useMemo, Fragment } from "react";
+
+import { HttpTimelinePostInfo } from "~src/http/timeline";
+
+import TimelinePostView from "./TimelinePostView";
+import TimelineDateLabel from "./TimelineDateLabel";
+
+import "./TimelinePostList.css";
+
+function dateEqual(left: Date, right: Date): boolean {
+ return (
+ left.getDate() == right.getDate() &&
+ left.getMonth() == right.getMonth() &&
+ left.getFullYear() == right.getFullYear()
+ );
+}
+
+interface TimelinePostListViewProps {
+ posts: HttpTimelinePostInfo[];
+ onReload: () => void;
+}
+
+export default function TimelinePostList(props: TimelinePostListViewProps) {
+ const { posts, onReload } = props;
+
+ const groupedPosts = useMemo<
+ {
+ date: Date;
+ posts: (HttpTimelinePostInfo & { index: number })[];
+ }[]
+ >(() => {
+ const result: {
+ date: Date;
+ posts: (HttpTimelinePostInfo & { index: number })[];
+ }[] = [];
+ let index = 0;
+ for (const post of posts) {
+ const time = new Date(post.time);
+ if (result.length === 0) {
+ result.push({ date: time, posts: [{ ...post, index }] });
+ } else {
+ const lastGroup = result[result.length - 1];
+ if (dateEqual(lastGroup.date, time)) {
+ lastGroup.posts.push({ ...post, index });
+ } else {
+ result.push({ date: time, posts: [{ ...post, index }] });
+ }
+ }
+ index++;
+ }
+ return result;
+ }, [posts]);
+
+ return (
+ <div>
+ {groupedPosts.map((group) => {
+ return (
+ <Fragment key={group.date.toDateString()}>
+ <TimelineDateLabel date={group.date} />
+ {group.posts.map((post) => {
+ return (
+ <TimelinePostView
+ key={post.id}
+ post={post}
+ onChanged={onReload}
+ onDeleted={onReload}
+ />
+ );
+ })}
+ </Fragment>
+ );
+ })}
+ </div>
+ );
+}
diff --git a/FrontEnd/src/pages/timeline/TimelinePostView.css b/FrontEnd/src/pages/timeline/TimelinePostView.css
new file mode 100644
index 00000000..a8db46bf
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelinePostView.css
@@ -0,0 +1,37 @@
+.timeline-post-header {
+ display: flex;
+ align-items: center;
+}
+
+.timeline-post-author-avatar {
+ border-radius: 50%;
+ width: 2em;
+ height: 2em;
+}
+
+.timeline-post-author-nickname {
+ margin: 0 1em;
+}
+
+.timeline-post-edit-button {
+ float: right;
+}
+
+.timeline-post-options-mask {
+ position: absolute;
+ inset: 0;
+ background-color: hsla(0, 0%, 100%, 0.9);
+ display: flex;
+ align-items: center;
+ justify-content: space-around;
+}
+
+@media (prefers-color-scheme: dark) {
+ .timeline-post-options-mask {
+ background-color: hsla(0, 0%, 0%, 0.8);
+ }
+}
+
+.timeline-post-content {
+ margin-top: 0.5em;
+}
diff --git a/FrontEnd/src/pages/timeline/TimelinePostView.tsx b/FrontEnd/src/pages/timeline/TimelinePostView.tsx
new file mode 100644
index 00000000..4f0460ff
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelinePostView.tsx
@@ -0,0 +1,123 @@
+import { useState } from "react";
+
+import {
+ getHttpTimelineClient,
+ HttpTimelinePostInfo,
+} from "~src/http/timeline";
+
+import { pushAlert } from "~src/components/alert";
+import { useClickOutside } from "~src/components/hooks";
+import UserAvatar from "~src/components/user/UserAvatar";
+import { DialogProvider, useDialog } from "~src/components/dialog";
+import FlatButton from "~src/components/button/FlatButton";
+import ConfirmDialog from "~src/components/dialog/ConfirmDialog";
+import TimelinePostContentView from "./view/TimelinePostContentView";
+import IconButton from "~src/components/button/IconButton";
+
+import TimelinePostContainer from "./TimelinePostContainer";
+import TimelinePostCard from "./TimelinePostCard";
+
+import "./TimelinePostView.css";
+
+interface TimelinePostViewProps {
+ post: HttpTimelinePostInfo;
+ className?: string;
+ onChanged: (post: HttpTimelinePostInfo) => void;
+ onDeleted: () => void;
+}
+
+export default function TimelinePostView(props: TimelinePostViewProps) {
+ const { post, onDeleted } = props;
+
+ const [operationMaskVisible, setOperationMaskVisible] =
+ useState<boolean>(false);
+
+ const { controller, switchDialog } = useDialog(
+ {
+ delete: (
+ <ConfirmDialog
+ title="timeline.post.deleteDialog.title"
+ body="timeline.post.deleteDialog.prompt"
+ onConfirm={() => {
+ void getHttpTimelineClient()
+ .deletePost(post.timelineOwnerV2, post.timelineNameV2, post.id)
+ .then(onDeleted, () => {
+ pushAlert({
+ color: "danger",
+ message: "timeline.deletePostFailed",
+ });
+ });
+ }}
+ />
+ ),
+ },
+ {
+ onClose: {
+ delete: () => {
+ setOperationMaskVisible(false);
+ },
+ },
+ },
+ );
+
+ const [maskElement, setMaskElement] = useState<HTMLElement | null>(null);
+ useClickOutside(maskElement, () => setOperationMaskVisible(false));
+
+ return (
+ <TimelinePostContainer>
+ <TimelinePostCard className="cru-primary">
+ {post.editable && (
+ <IconButton
+ color="primary"
+ icon="chevron-down"
+ className="timeline-post-edit-button"
+ onClick={(e) => {
+ setOperationMaskVisible(true);
+ e.stopPropagation();
+ }}
+ />
+ )}
+ <div className="timeline-post-header">
+ <UserAvatar
+ username={post.author.username}
+ className="timeline-post-author-avatar"
+ />
+ <small className="timeline-post-author-nickname">
+ {post.author.nickname}
+ </small>
+ <small className="timeline-post-time">
+ {new Date(post.time).toLocaleTimeString()}
+ </small>
+ </div>
+ <div className="timeline-post-content">
+ <TimelinePostContentView post={post} />
+ </div>
+ {operationMaskVisible ? (
+ <div
+ ref={setMaskElement}
+ className="timeline-post-options-mask"
+ onClick={() => {
+ setOperationMaskVisible(false);
+ }}
+ >
+ <FlatButton
+ text="changeProperty"
+ onClick={(e) => {
+ e.stopPropagation();
+ }}
+ />
+ <FlatButton
+ text="delete"
+ color="danger"
+ onClick={(e) => {
+ switchDialog("delete");
+ e.stopPropagation();
+ }}
+ />
+ </div>
+ ) : null}
+ </TimelinePostCard>
+ <DialogProvider controller={controller} />
+ </TimelinePostContainer>
+ );
+}
diff --git a/FrontEnd/src/pages/timeline/TimelinePropertyChangeDialog.tsx b/FrontEnd/src/pages/timeline/TimelinePropertyChangeDialog.tsx
new file mode 100644
index 00000000..79838d58
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelinePropertyChangeDialog.tsx
@@ -0,0 +1,79 @@
+import {
+ getHttpTimelineClient,
+ HttpTimelineInfo,
+ HttpTimelinePatchRequest,
+ kTimelineVisibilities,
+ TimelineVisibility,
+} from "~src/http/timeline";
+
+import OperationDialog from "~src/components/dialog/OperationDialog";
+
+interface TimelinePropertyChangeDialogProps {
+ timeline: HttpTimelineInfo;
+ onChange: () => void;
+}
+
+const labelMap: { [key in TimelineVisibility]: string } = {
+ Private: "timeline.visibility.private",
+ Public: "timeline.visibility.public",
+ Register: "timeline.visibility.register",
+};
+
+export default function TimelinePropertyChangeDialog({
+ timeline,
+ onChange,
+}: TimelinePropertyChangeDialogProps) {
+ return (
+ <OperationDialog
+ title={"timeline.dialogChangeProperty.title"}
+ inputs={{
+ scheme: {
+ inputs: [
+ {
+ key: "title",
+ type: "text",
+ label: "timeline.dialogChangeProperty.titleField",
+ },
+ {
+ key: "visibility",
+ type: "select",
+ label: "timeline.dialogChangeProperty.visibility",
+ options: kTimelineVisibilities.map((v) => ({
+ label: labelMap[v],
+ value: v,
+ })),
+ },
+ {
+ key: "description",
+ type: "text",
+ label: "timeline.dialogChangeProperty.description",
+ },
+ ],
+ },
+ dataInit: {
+ values: {
+ title: timeline.title,
+ visibility: timeline.visibility,
+ description: timeline.description,
+ },
+ },
+ }}
+ onProcess={({ title, visibility, description }) => {
+ const req: HttpTimelinePatchRequest = {};
+ if (title !== timeline.title) {
+ req.title = title;
+ }
+ if (visibility !== timeline.visibility) {
+ req.visibility = visibility;
+ }
+ if (description !== timeline.description) {
+ req.description = description;
+ }
+ return getHttpTimelineClient()
+ .patchTimeline(timeline.owner.username, timeline.nameV2, req)
+ .then(onChange);
+ }}
+ />
+ );
+}
+
diff --git a/FrontEnd/src/pages/timeline/edit/ImagePostEdit.css b/FrontEnd/src/pages/timeline/edit/ImagePostEdit.css
new file mode 100644
index 00000000..232681c8
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/edit/ImagePostEdit.css
@@ -0,0 +1,5 @@
+.timeline-edit-image-image {
+ max-width: 100px;
+ max-height: 100px;
+}
+
diff --git a/FrontEnd/src/pages/timeline/edit/ImagePostEdit.tsx b/FrontEnd/src/pages/timeline/edit/ImagePostEdit.tsx
new file mode 100644
index 00000000..c62c8ee5
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/edit/ImagePostEdit.tsx
@@ -0,0 +1,36 @@
+import classNames from "classnames";
+
+import BlobImage from "~/src/components/BlobImage";
+
+import "./ImagePostEdit.css";
+
+interface TimelinePostEditImageProps {
+ file: File | null;
+ onChange: (file: File | null) => void;
+ disabled: boolean;
+ className?: string;
+}
+
+export default function ImagePostEdit(props: TimelinePostEditImageProps) {
+ const { file, onChange, disabled, className } = props;
+
+ return (
+ <div className={classNames("timeline-edit-image-container", className)}>
+ <input
+ type="file"
+ accept="image/*"
+ disabled={disabled}
+ onChange={(e) => {
+ const files = e.target.files;
+ if (files == null || files.length === 0) {
+ onChange(null);
+ } else {
+ onChange(files[0]);
+ }
+ }}
+ className="timeline-edit-image-input"
+ />
+ {file && <BlobImage src={file} className="timeline-edit-image-image" />}
+ </div>
+ );
+}
diff --git a/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.css b/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.css
new file mode 100644
index 00000000..c5b41b40
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.css
@@ -0,0 +1,24 @@
+.timeline-edit-markdown-tab-page {
+ min-height: 8em;
+ display: flex;
+}
+
+.timeline-edit-markdown-text {
+ width: 100%;
+}
+
+.timeline-edit-markdown-images {
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.timeline-edit-markdown-images img {
+ max-width: 100%;
+ max-height: 200px;
+}
+
+.timeline-edit-markdown-preview img {
+ max-width: 100%;
+ max-height: 200px;
+}
+
diff --git a/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.tsx b/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.tsx
new file mode 100644
index 00000000..36a5572b
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.tsx
@@ -0,0 +1,199 @@
+import { useEffect, useState } from "react";
+import classnames from "classnames";
+import { marked } from "marked";
+
+import { HttpTimelinePostPostRequestData } from "~src/http/timeline";
+
+import base64 from "~src/utilities/base64";
+
+import { array } from "~src/components/common";
+import { TabPages } from "~src/components/tab";
+import { IconButton } from "~src/components/button";
+import BlobImage from "~src/components/BlobImage";
+
+import "./MarkdownPostEdit.css";
+
+class MarkedRenderer extends marked.Renderer {
+ constructor(public images: string[]) {
+ super();
+ }
+
+ // Custom image parser for indexed image link.
+ image(href: string, title: string | null, text: string): string {
+ const i = parseInt(href);
+ if (!isNaN(i) && i > 0 && i <= this.images.length) {
+ href = this.images[i - 1];
+ }
+
+ return super.image(href, title, text);
+ }
+}
+
+function generateMarkedOptions(imageUrls: string[]) {
+ return {
+ renderer: new MarkedRenderer(imageUrls),
+ async: false,
+ } as const;
+}
+
+function renderHtml(text: string, imageUrls: string[]): string {
+ return marked.parse(text, generateMarkedOptions(imageUrls));
+}
+
+async function build(
+ text: string,
+ images: File[],
+): Promise<HttpTimelinePostPostRequestData[]> {
+ return [
+ {
+ contentType: "text/markdown",
+ data: await base64(text),
+ },
+ ...(await Promise.all(
+ images.map(async (image) => {
+ const data = await base64(image);
+ return { contentType: image.type, data };
+ }),
+ )),
+ ];
+}
+
+export function useMarkdownEdit(disabled: boolean): {
+ hasContent: boolean;
+ clear: () => void;
+ build: () => Promise<HttpTimelinePostPostRequestData[]>;
+ markdownEditProps: Omit<MarkdownPostEditProps, "className">;
+} {
+ const [text, setText] = useState<string>("");
+ const [images, setImages] = useState<File[]>([]);
+
+ return {
+ hasContent: text !== "" || images.length !== 0,
+ clear: () => {
+ setText("");
+ setImages([]);
+ },
+ build: () => {
+ return build(text, images);
+ },
+ markdownEditProps: {
+ disabled,
+ text,
+ images,
+ onTextChange: setText,
+ onImageAppend: (image) => setImages(array.copy_push(images, image)),
+ onImageMove: (o, n) => setImages(array.copy_move(images, o, n)),
+ onImageDelete: (i) => setImages(array.copy_delete(images, i)),
+ },
+ };
+}
+
+function MarkdownPreview({ text, images }: { text: string; images: File[] }) {
+ const [html, setHtml] = useState("");
+
+ useEffect(() => {
+ const imageUrls = images.map((image) => URL.createObjectURL(image));
+
+ setHtml(renderHtml(text, imageUrls));
+
+ return () => {
+ imageUrls.forEach((url) => URL.revokeObjectURL(url));
+ };
+ }, [text, images]);
+
+ return (
+ <div
+ className="timeline-edit-markdown-preview"
+ dangerouslySetInnerHTML={{ __html: html }}
+ />
+ );
+}
+
+interface MarkdownPostEditProps {
+ disabled: boolean;
+ text: string;
+ images: File[];
+ onTextChange: (text: string) => void;
+ onImageAppend: (image: File) => void;
+ onImageMove: (oldIndex: number, newIndex: number) => void;
+ onImageDelete: (index: number) => void;
+ className?: string;
+}
+
+export function MarkdownPostEdit({
+ disabled,
+ text,
+ images,
+ onTextChange,
+ onImageAppend,
+ // onImageMove,
+ onImageDelete,
+ className,
+}: MarkdownPostEditProps) {
+ return (
+ <TabPages
+ className={className}
+ pageContainerClassName="timeline-edit-markdown-tab-page"
+ dense
+ pages={[
+ {
+ name: "text",
+ text: "edit",
+ page: (
+ <textarea
+ value={text}
+ disabled={disabled}
+ className="timeline-edit-markdown-text"
+ onChange={(event) => {
+ onTextChange(event.currentTarget.value);
+ }}
+ />
+ ),
+ },
+ {
+ name: "images",
+ text: "image",
+ page: (
+ <div className="timeline-edit-markdown-images">
+ {images.map((image, index) => (
+ <div
+ key={image.name}
+ className="timeline-edit-markdown-image-container"
+ >
+ <BlobImage src={image} />
+ <IconButton
+ icon="trash"
+ color="danger"
+ className={classnames(
+ "timeline-edit-markdown-image-delete",
+ process && "d-none",
+ )}
+ onClick={() => {
+ onImageDelete(index);
+ }}
+ />
+ </div>
+ ))}
+ <input
+ type="file"
+ accept="image/jpeg,image/jpg,image/png,image/gif,image/webp"
+ onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
+ const { files } = event.currentTarget;
+ if (files != null && files.length !== 0) {
+ onImageAppend(files[0]);
+ }
+ }}
+ disabled={disabled}
+ />
+ </div>
+ ),
+ },
+ {
+ name: "preview",
+ text: "preview",
+ page: <MarkdownPreview text={text} images={images} />,
+ },
+ ]}
+ />
+ );
+}
diff --git a/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.css b/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.css
new file mode 100644
index 00000000..d1a61793
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.css
@@ -0,0 +1,12 @@
+.timeline-edit-plain-text-container {
+ width: 100%;
+ height: 100%;
+}
+
+.timeline-edit-plain-text-input {
+ width: 100%;
+ height: 100%;
+ padding: 0.5em;
+ border-radius: 4px;
+}
+
diff --git a/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.tsx b/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.tsx
new file mode 100644
index 00000000..7f3663b2
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.tsx
@@ -0,0 +1,29 @@
+import classNames from "classnames";
+
+import "./PlainTextPostEdit.css";
+
+interface TimelinePostEditTextProps {
+ text: string;
+ disabled: boolean;
+ onChange: (text: string) => void;
+ className?: string;
+}
+
+export default function TimelinePostEditText(props: TimelinePostEditTextProps) {
+ const { text, disabled, onChange, className } = props;
+
+ return (
+ <div
+ className={classNames("timeline-edit-plain-text-container", className)}
+ >
+ <textarea
+ value={text}
+ disabled={disabled}
+ onChange={(event) => {
+ onChange(event.target.value);
+ }}
+ className={classNames("timeline-edit-plain-text-input")}
+ />
+ </div>
+ );
+}
diff --git a/FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.css b/FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.css
new file mode 100644
index 00000000..6efe93e9
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.css
@@ -0,0 +1,35 @@
+.timeline-post-create-card {
+ position: sticky !important;
+ top: 106px;
+ z-index: 100;
+ margin-right: 200px;
+}
+
+@media (max-width: 576px) {
+ .timeline-post-create-container {
+ padding-top: 60px;
+ }
+
+ .timeline-post-create-card {
+ margin-right: 0;
+ }
+}
+
+.timeline-post-create {
+ display: flex;
+}
+
+.timeline-post-create-edit-area {
+ flex-grow: 1;
+}
+
+.timeline-post-create-right-area {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ margin-left: 1em;
+}
+
+.timeline-post-create-send {
+ margin-top: auto;
+}
diff --git a/FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.tsx b/FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.tsx
new file mode 100644
index 00000000..c0a80ad0
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.tsx
@@ -0,0 +1,193 @@
+import { useState } from "react";
+import classNames from "classnames";
+
+import { UiLogicError } from "~src/common";
+
+import {
+ getHttpTimelineClient,
+ HttpTimelineInfo,
+ HttpTimelinePostInfo,
+ HttpTimelinePostPostRequestData,
+} from "~src/http/timeline";
+
+import base64 from "~src/utilities/base64";
+
+import { useC } from "~/src/components/common";
+import { pushAlert } from "~src/components/alert";
+import { IconButton, LoadingButton } from "~src/components/button";
+import PopupMenu from "~src/components/menu/PopupMenu";
+import { useWindowLeave } from "~src/components/hooks";
+
+import TimelinePostCard from "../TimelinePostCard";
+import TimelinePostContainer from "../TimelinePostContainer";
+import PlainTextPostEdit from "./PlainTextPostEdit";
+import ImagePostEdit from "./ImagePostEdit";
+import { MarkdownPostEdit, useMarkdownEdit } from "./MarkdownPostEdit";
+
+import "./TimelinePostCreateView.css";
+
+type PostKind = "text" | "markdown" | "image";
+
+const postKindIconMap: Record<PostKind, string> = {
+ text: "fonts",
+ markdown: "markdown",
+ image: "image",
+};
+
+export interface TimelinePostEditProps {
+ className?: string;
+ timeline: HttpTimelineInfo;
+ onPosted: (newPost: HttpTimelinePostInfo) => void;
+}
+
+function TimelinePostEdit(props: TimelinePostEditProps) {
+ const { timeline, className, onPosted } = props;
+
+ const c = useC();
+
+ const [process, setProcess] = useState<boolean>(false);
+
+ const [kind, setKind] = useState<PostKind>("text");
+
+ const draftTextLocalStorageKey = `timeline.${timeline.owner.username}.${timeline.nameV2}.postDraft.text`;
+ const [text, setText] = useState<string>(
+ () => window.localStorage.getItem(draftTextLocalStorageKey) ?? "",
+ );
+ const [image, setImage] = useState<File | null>(null);
+ const {
+ hasContent: mdHasContent,
+ build: mdBuild,
+ clear: mdClear,
+ markdownEditProps,
+ } = useMarkdownEdit(process);
+
+ useWindowLeave(!mdHasContent && !image);
+
+ const canSend =
+ (kind === "text" && text.length !== 0) ||
+ (kind === "image" && image != null) ||
+ (kind === "markdown" && mdHasContent);
+
+ const onPostError = (): void => {
+ pushAlert({
+ color: "danger",
+ message: "timeline.sendPostFailed",
+ });
+ };
+
+ const onSend = async (): Promise<void> => {
+ setProcess(true);
+
+ let requestDataList: HttpTimelinePostPostRequestData[];
+ switch (kind) {
+ case "text":
+ requestDataList = [
+ {
+ contentType: "text/plain",
+ data: await base64(text),
+ },
+ ];
+ break;
+ case "image":
+ if (image == null) {
+ throw new UiLogicError();
+ }
+ requestDataList = [
+ {
+ contentType: image.type,
+ data: await base64(image),
+ },
+ ];
+ break;
+ case "markdown":
+ if (!mdHasContent) {
+ throw new UiLogicError();
+ }
+ requestDataList = await mdBuild();
+ break;
+ default:
+ throw new UiLogicError("Unknown content type.");
+ }
+
+ try {
+ const res = await getHttpTimelineClient().postPost(
+ timeline.owner.username,
+ timeline.nameV2,
+ {
+ dataList: requestDataList,
+ },
+ );
+
+ if (kind === "text") {
+ setText("");
+ window.localStorage.removeItem(draftTextLocalStorageKey);
+ } else if (kind === "image") {
+ setImage(null);
+ } else if (kind === "markdown") {
+ mdClear();
+ }
+ onPosted(res);
+ } catch (e) {
+ onPostError();
+ } finally {
+ setProcess(false);
+ }
+ };
+
+ return (
+ <TimelinePostContainer
+ className={classNames(className, "timeline-post-create-container")}
+ >
+ <TimelinePostCard className="timeline-post-create-card">
+ <div className="timeline-post-create">
+ <div className="timeline-post-create-edit-area">
+ {kind === "text" && (
+ <PlainTextPostEdit
+ text={text}
+ disabled={process}
+ onChange={(text) => {
+ setText(text);
+ window.localStorage.setItem(draftTextLocalStorageKey, text);
+ }}
+ />
+ )}
+ {kind === "image" && (
+ <ImagePostEdit
+ file={image}
+ onChange={setImage}
+ disabled={process}
+ />
+ )}
+ {kind === "markdown" && <MarkdownPostEdit {...markdownEditProps} />}
+ </div>
+ <div className="timeline-post-create-right-area">
+ <PopupMenu
+ containerClassName="timeline-post-create-kind-select"
+ items={(["text", "image", "markdown"] as const).map((kind) => ({
+ type: "button",
+ text: `timeline.post.type.${kind}`,
+ iconClassName: postKindIconMap[kind],
+ onClick: () => {
+ setKind(kind);
+ },
+ }))}
+ >
+ <IconButton color="primary" icon={postKindIconMap[kind]} />
+ </PopupMenu>
+ <LoadingButton
+ className="timeline-post-create-send"
+ onClick={() => void onSend()}
+ color="primary"
+ disabled={!canSend}
+ loading={process}
+ >
+ {c("timeline.send")}
+ </LoadingButton>
+ </div>
+ </div>
+ </TimelinePostCard>
+ </TimelinePostContainer>
+ );
+}
+
+export default TimelinePostEdit;
diff --git a/FrontEnd/src/pages/timeline/index.tsx b/FrontEnd/src/pages/timeline/index.tsx
new file mode 100644
index 00000000..6cd1ded0
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/index.tsx
@@ -0,0 +1,16 @@
+import { useParams } from "react-router-dom";
+
+import { UiLogicError } from "~src/common";
+
+import Timeline from "./Timeline";
+
+export default function TimelinePage() {
+ const { owner, timeline: timelineNameParam } = useParams();
+
+ if (owner == null || owner == "")
+ throw new UiLogicError("Route param owner is not set.");
+
+ const timeline = timelineNameParam || "self";
+
+ return <Timeline timelineOwner={owner} timelineName={timeline} />;
+};
diff --git a/FrontEnd/src/pages/timeline/view/ImagePostView.css b/FrontEnd/src/pages/timeline/view/ImagePostView.css
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/view/ImagePostView.css
diff --git a/FrontEnd/src/pages/timeline/view/ImagePostView.tsx b/FrontEnd/src/pages/timeline/view/ImagePostView.tsx
new file mode 100644
index 00000000..85179475
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/view/ImagePostView.tsx
@@ -0,0 +1,38 @@
+import { useEffect, useState } from "react";
+import classNames from "classnames";
+
+import {
+ HttpTimelinePostInfo,
+ getHttpTimelineClient,
+} from "~src/http/timeline";
+
+import "./ImagePostView.css";
+
+interface ImagePostViewProps {
+ post?: HttpTimelinePostInfo;
+ className?: string;
+}
+
+export default function ImagePostView({ post, className }: ImagePostViewProps) {
+ const [url, setUrl] = useState<string | null>(null);
+
+ useEffect(() => {
+ if (post) {
+ setUrl(
+ getHttpTimelineClient().generatePostDataUrl(
+ post.timelineOwnerV2,
+ post.timelineNameV2,
+ post.id,
+ ),
+ );
+ } else {
+ setUrl(null);
+ }
+ }, [post]);
+
+ return (
+ <div className={classNames("timeline-view-image-container", className)}>
+ <img src={url ?? undefined} className="timeline-view-image" />
+ </div>
+ );
+}
diff --git a/FrontEnd/src/pages/timeline/view/MarkdownPostView.css b/FrontEnd/src/pages/timeline/view/MarkdownPostView.css
new file mode 100644
index 00000000..48a893eb
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/view/MarkdownPostView.css
@@ -0,0 +1,4 @@
+.timeline-view-markdown img {
+ max-width: 100%;
+ max-height: 200px;
+}
diff --git a/FrontEnd/src/pages/timeline/view/MarkdownPostView.tsx b/FrontEnd/src/pages/timeline/view/MarkdownPostView.tsx
new file mode 100644
index 00000000..9bb9f980
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/view/MarkdownPostView.tsx
@@ -0,0 +1,59 @@
+import { useMemo, useState } from "react";
+import { marked } from "marked";
+import classNames from "classnames";
+
+import {
+ HttpTimelinePostInfo,
+ getHttpTimelineClient,
+} from "~src/http/timeline";
+
+import { useAutoUnsubscribePromise } from "~src/components/hooks";
+import Skeleton from "~src/components/Skeleton";
+
+import "./MarkdownPostView.css";
+
+interface MarkdownPostViewProps {
+ post?: HttpTimelinePostInfo;
+ className?: string;
+}
+
+export default function MarkdownPostView({
+ post,
+ className,
+}: MarkdownPostViewProps) {
+ const [markdown, setMarkdown] = useState<string | null>(null);
+
+ useAutoUnsubscribePromise(
+ () => {
+ if (post) {
+ return getHttpTimelineClient().getPostDataAsString(
+ post.timelineOwnerV2,
+ post.timelineNameV2,
+ post.id,
+ );
+ }
+ },
+ setMarkdown,
+ [post],
+ );
+
+ const markdownHtml = useMemo<string | null>(() => {
+ if (markdown == null) return null;
+ return marked.parse(markdown, {
+ async: false,
+ });
+ }, [markdown]);
+
+ return (
+ <div className={classNames("timeline-view-markdown-container", className)}>
+ {markdownHtml == null ? (
+ <Skeleton />
+ ) : (
+ <div
+ className="timeline-view-markdown"
+ dangerouslySetInnerHTML={{ __html: markdownHtml }}
+ />
+ )}
+ </div>
+ );
+}
diff --git a/FrontEnd/src/pages/timeline/view/PlainTextPostView.css b/FrontEnd/src/pages/timeline/view/PlainTextPostView.css
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/view/PlainTextPostView.css
diff --git a/FrontEnd/src/pages/timeline/view/PlainTextPostView.tsx b/FrontEnd/src/pages/timeline/view/PlainTextPostView.tsx
new file mode 100644
index 00000000..b964187d
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/view/PlainTextPostView.tsx
@@ -0,0 +1,50 @@
+import { useState } from "react";
+import classNames from "classnames";
+
+import {
+ HttpTimelinePostInfo,
+ getHttpTimelineClient,
+} from "~src/http/timeline";
+
+import Skeleton from "~src/components/Skeleton";
+import { useAutoUnsubscribePromise } from "~src/components/hooks";
+
+import "./PlainTextPostView.css";
+
+interface PlainTextPostViewProps {
+ post?: HttpTimelinePostInfo;
+ className?: string;
+}
+
+export default function PlainTextPostView({
+ post,
+ className,
+}: PlainTextPostViewProps) {
+ const [text, setText] = useState<string | null>(null);
+
+ useAutoUnsubscribePromise(
+ () => {
+ if (post) {
+ return getHttpTimelineClient().getPostDataAsString(
+ post.timelineOwnerV2,
+ post.timelineNameV2,
+ post.id,
+ );
+ }
+ },
+ setText,
+ [post],
+ );
+
+ return (
+ <div
+ className={classNames("timeline-view-plain-text-container", className)}
+ >
+ {text == null ? (
+ <Skeleton />
+ ) : (
+ <div className="timeline-view-plain-text">{text}</div>
+ )}
+ </div>
+ );
+}
diff --git a/FrontEnd/src/pages/timeline/view/TimelinePostContentView.tsx b/FrontEnd/src/pages/timeline/view/TimelinePostContentView.tsx
new file mode 100644
index 00000000..851a9a33
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/view/TimelinePostContentView.tsx
@@ -0,0 +1,37 @@
+import ImagePostView from "./ImagePostView";
+import MarkdownPostView from "./MarkdownPostView";
+import PlainTextPostView from "./PlainTextPostView";
+
+import type { HttpTimelinePostInfo } from "~src/http/timeline";
+
+interface TimelinePostContentViewProps {
+ post?: HttpTimelinePostInfo;
+ className?: string;
+}
+
+const viewMap: Record<string, React.FC<TimelinePostContentViewProps>> = {
+ "text/plain": PlainTextPostView,
+ "text/markdown": MarkdownPostView,
+ "image/png": ImagePostView,
+ "image/jpeg": ImagePostView,
+ "image/gif": ImagePostView,
+ "image/webp": ImagePostView,
+};
+
+export default function TimelinePostContentView({
+ post,
+ className,
+}: TimelinePostContentViewProps) {
+ if (post == null) {
+ return <div />;
+ }
+
+ const type = post.dataList[0].kind;
+
+ if (type in viewMap) {
+ const View = viewMap[type];
+ return <View post={post} className={className} />;
+ }
+
+ return <div>Unknown post type.</div>;
+}