From a9dc6b16d6730d8d1dc1ea2fab8ab3830fe56ce4 Mon Sep 17 00:00:00 2001
From: crupest <crupest@outlook.com>
Date: Fri, 28 Jul 2023 00:40:58 +0800
Subject: ...

---
 FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

(limited to 'FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx')

diff --git a/FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx b/FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx
index c960b3c2..3b5ba42f 100644
--- a/FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx
+++ b/FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx
@@ -20,7 +20,7 @@ const TimelineDeleteDialog: React.FC<TimelineDeleteDialog> = (props) => {
   return (
     <OperationDialog
       open={props.open}
-      onClose={props.close}
+      close={props.close}
       title="timeline.deleteDialog.title"
       themeColor="danger"
       inputPrompt={() => {
-- 
cgit v1.2.3


From 22e8f24e7f7574915e4c75d3c6a5498f6e621ee8 Mon Sep 17 00:00:00 2001
From: crupest <crupest@outlook.com>
Date: Sat, 29 Jul 2023 22:00:59 +0800
Subject: ...

---
 FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx  | 15 +++---
 .../src/pages/setting/ChangeNicknameDialog.tsx     |  6 +--
 .../src/pages/setting/ChangePasswordDialog.tsx     |  6 +--
 FrontEnd/src/pages/setting/index.tsx               | 46 +++++++-----------
 FrontEnd/src/views/admin/UserAdmin.tsx             |  8 ++--
 FrontEnd/src/views/center/TimelineCreateDialog.tsx |  2 +-
 FrontEnd/src/views/common/button/LoadingButton.tsx |  5 +-
 .../src/views/common/dialog/OperationDialog.tsx    | 22 +++++----
 FrontEnd/src/views/common/dialog/index.ts          | 56 ++++++++++++++++++++++
 FrontEnd/src/views/common/input/InputGroup.tsx     |  7 ++-
 .../views/timeline/PostPropertyChangeDialog.tsx    |  2 +-
 .../src/views/timeline/TimelineDeleteDialog.tsx    |  2 +-
 .../timeline/TimelinePropertyChangeDialog.tsx      |  2 +-
 13 files changed, 116 insertions(+), 63 deletions(-)
 create mode 100644 FrontEnd/src/views/common/dialog/index.ts

(limited to 'FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx')

diff --git a/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx b/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx
index b2a4e2a8..8c8e04fe 100644
--- a/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx
+++ b/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx
@@ -9,13 +9,16 @@ import { useUser } from "@/services/user";
 
 import { getHttpUserClient } from "@/http/user";
 
-import ImageCropper, { Clip, applyClipToImage } from "@/views/common/ImageCropper";
+import ImageCropper, {
+  Clip,
+  applyClipToImage,
+} from "@/views/common/ImageCropper";
 import Button from "@/views/common/button/Button";
 import Dialog from "@/views/common/dialog/Dialog";
 
 export interface ChangeAvatarDialogProps {
   open: boolean;
-  close: () => void;
+  onClose: () => void;
 }
 
 const ChangeAvatarDialog: React.FC<ChangeAvatarDialogProps> = (props) => {
@@ -42,12 +45,12 @@ const ChangeAvatarDialog: React.FC<ChangeAvatarDialogProps> = (props) => {
   >("select");
 
   const [message, setMessage] = useState<I18nText>(
-    "settings.dialogChangeAvatar.prompt.select"
+    "settings.dialogChangeAvatar.prompt.select",
   );
 
   const trueMessage = convertI18nText(message, t);
 
-  const closeDialog = props.close;
+  const closeDialog = props.onClose;
 
   const close = React.useCallback((): void => {
     if (!(state === "uploading")) {
@@ -92,7 +95,7 @@ const ChangeAvatarDialog: React.FC<ChangeAvatarDialogProps> = (props) => {
         setFile(files[0]);
       }
     },
-    []
+    [],
   );
 
   const onCropNext = React.useCallback(() => {
@@ -140,7 +143,7 @@ const ChangeAvatarDialog: React.FC<ChangeAvatarDialogProps> = (props) => {
         (e: unknown) => {
           setState("error");
           setMessage({ type: "custom", value: (e as AxiosError).message });
-        }
+        },
       );
   }, [user, resultBlob]);
 
diff --git a/FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx b/FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx
index 5606ce94..4d318543 100644
--- a/FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx
+++ b/FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx
@@ -5,11 +5,11 @@ import OperationDialog from "@/views/common/dialog/OperationDialog";
 
 export interface ChangeNicknameDialogProps {
   open: boolean;
-  close: () => void;
+  onClose: () => void;
 }
 
 export default function ChangeNicknameDialog(props: ChangeNicknameDialogProps) {
-  const { open, close } = props;
+  const { open, onClose } = props;
 
   const user = useUserLoggedIn();
 
@@ -29,7 +29,7 @@ export default function ChangeNicknameDialog(props: ChangeNicknameDialogProps) {
           nickname: newNickname as string,
         });
       }}
-      close={close}
+      onClose={onClose}
     />
   );
 }
diff --git a/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx b/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx
index 407f3051..87a970a5 100644
--- a/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx
+++ b/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx
@@ -9,11 +9,11 @@ import OperationDialog, {
 
 interface ChangePasswordDialogProps {
   open: boolean;
-  close: () => void;
+  onClose: () => void;
 }
 
 export function ChangePasswordDialog(props: ChangePasswordDialogProps) {
-  const { open, close } = props;
+  const { open, onClose } = props;
 
   const navigate = useNavigate();
 
@@ -22,7 +22,7 @@ export function ChangePasswordDialog(props: ChangePasswordDialogProps) {
   return (
     <OperationDialog
       open={open}
-      close={close}
+      onClose={onClose}
       title="settings.dialogChangePassword.title"
       color="danger"
       inputPrompt="settings.dialogChangePassword.prompt"
diff --git a/FrontEnd/src/pages/setting/index.tsx b/FrontEnd/src/pages/setting/index.tsx
index cec81530..12a7670e 100644
--- a/FrontEnd/src/pages/setting/index.tsx
+++ b/FrontEnd/src/pages/setting/index.tsx
@@ -12,6 +12,7 @@ import { useC, Text } from "@/common";
 import { useUser, userService } from "@/services/user";
 import { getHttpUserClient } from "@/http/user";
 
+import { useDialog } from "@/views/common/dialog";
 import ConfirmDialog from "@/views/common/dialog/ConfirmDialog";
 import Card from "@/views/common/Card";
 import Spinner from "@/views/common/Spinner";
@@ -237,18 +238,13 @@ export default function SettingPage() {
   const user = useUser();
   const navigate = useNavigate();
 
-  type DialogName =
-    | "change-password"
-    | "change-avatar"
-    | "change-nickname"
-    | "logout"
-    | "renew-register-code";
-
-  const [dialog, setDialog] = useState<null | DialogName>(null);
-
-  function dialogOpener(name: DialogName): () => void {
-    return () => setDialog(name);
-  }
+  const { dialogPropsMap, createDialogSwitch } = useDialog([
+    "change-password",
+    "change-avatar",
+    "change-nickname",
+    "logout",
+    "renew-register-code",
+  ]);
 
   return (
     <Page noTopPadding>
@@ -257,20 +253,20 @@ export default function SettingPage() {
           <RegisterCodeSettingItem />
           <ButtonSettingItem
             title="settings.changeAvatar"
-            onClick={dialogOpener("change-avatar")}
+            onClick={createDialogSwitch("change-avatar")}
           />
           <ButtonSettingItem
             title="settings.changeNickname"
-            onClick={dialogOpener("change-nickname")}
+            onClick={createDialogSwitch("change-nickname")}
           />
           <ButtonSettingItem
             title="settings.changePassword"
-            onClick={dialogOpener("change-password")}
+            onClick={createDialogSwitch("change-password")}
             danger
           />
           <ButtonSettingItem
             title="settings.logout"
-            onClick={dialogOpener("logout")}
+            onClick={createDialogSwitch("logout")}
             danger
           />
         </SettingSection>
@@ -278,31 +274,21 @@ export default function SettingPage() {
       <SettingSection title="settings.subheader.customization">
         <LanguageChangeSettingItem />
       </SettingSection>
-      <ChangePasswordDialog
-        open={dialog === "change-password"}
-        close={() => setDialog(null)}
-      />
+      <ChangePasswordDialog {...dialogPropsMap["change-password"]} />
       {user && (
         <>
           <ConfirmDialog
             title="settings.dialogConfirmLogout.title"
             body="settings.dialogConfirmLogout.prompt"
-            onClose={() => setDialog(null)}
-            open={dialog === "logout"}
             onConfirm={() => {
               void userService.logout().then(() => {
                 navigate("/");
               });
             }}
+            {...dialogPropsMap["logout"]}
           />
-          <ChangeAvatarDialog
-            open={dialog === "change-avatar"}
-            close={() => setDialog(null)}
-          />
-          <ChangeNicknameDialog
-            open={dialog === "change-nickname"}
-            close={() => setDialog(null)}
-          />
+          <ChangeAvatarDialog {...dialogPropsMap["change-avatar"]} />
+          <ChangeNicknameDialog {...dialogPropsMap["change-nickname"]} />
         </>
       )}
     </Page>
diff --git a/FrontEnd/src/views/admin/UserAdmin.tsx b/FrontEnd/src/views/admin/UserAdmin.tsx
index 6003bd5a..d5179bf5 100644
--- a/FrontEnd/src/views/admin/UserAdmin.tsx
+++ b/FrontEnd/src/views/admin/UserAdmin.tsx
@@ -35,7 +35,7 @@ const CreateUserDialog: React.FC<{
           password,
         })
       }
-      close={close}
+      onClose={close}
       open={open}
       onSuccessAndClose={onSuccess}
     />
@@ -55,7 +55,7 @@ const UserDeleteDialog: React.FC<{
   return (
     <OperationDialog
       open={open}
-      close={close}
+      onClose={close}
       title="admin:user.dialog.delete.title"
       themeColor="danger"
       inputPrompt={() => (
@@ -78,7 +78,7 @@ const UserModifyDialog: React.FC<{
   return (
     <OperationDialog
       open={open}
-      close={close}
+      onClose={close}
       title="admin:user.dialog.modify.title"
       themeColor="danger"
       inputPrompt={() => (
@@ -126,7 +126,7 @@ const UserPermissionModifyDialog: React.FC<{
   return (
     <OperationDialog
       open={open}
-      close={close}
+      onClose={close}
       title="admin:user.dialog.modifyPermissions.title"
       themeColor="danger"
       inputPrompt={() => (
diff --git a/FrontEnd/src/views/center/TimelineCreateDialog.tsx b/FrontEnd/src/views/center/TimelineCreateDialog.tsx
index 8d4dde10..63742936 100644
--- a/FrontEnd/src/views/center/TimelineCreateDialog.tsx
+++ b/FrontEnd/src/views/center/TimelineCreateDialog.tsx
@@ -20,7 +20,7 @@ const TimelineCreateDialog: React.FC<TimelineCreateDialogProps> = (props) => {
   return (
     <OperationDialog
       open={props.open}
-      close={props.close}
+      onClose={props.close}
       themeColor="success"
       title="home.createDialog.title"
       inputScheme={
diff --git a/FrontEnd/src/views/common/button/LoadingButton.tsx b/FrontEnd/src/views/common/button/LoadingButton.tsx
index f23369de..bfa5b6b8 100644
--- a/FrontEnd/src/views/common/button/LoadingButton.tsx
+++ b/FrontEnd/src/views/common/button/LoadingButton.tsx
@@ -15,7 +15,8 @@ interface LoadingButtonProps extends React.ComponentPropsWithoutRef<"button"> {
 export default function LoadingButton(props: LoadingButtonProps) {
   const c = useC();
 
-  const { color, text, loading, className, children, ...otherProps } = props;
+  const { color, text, loading, disabled, className, children, ...otherProps } =
+    props;
 
   if (text != null && children != null) {
     console.warn("You can't set both text and children props.");
@@ -23,7 +24,7 @@ export default function LoadingButton(props: LoadingButtonProps) {
 
   return (
     <button
-      disabled={loading}
+      disabled={disabled || loading}
       className={classNames(
         `cru-${color ?? "primary"} cru-button outline cru-loading-button`,
         className,
diff --git a/FrontEnd/src/views/common/dialog/OperationDialog.tsx b/FrontEnd/src/views/common/dialog/OperationDialog.tsx
index be3f7158..74b4a5fa 100644
--- a/FrontEnd/src/views/common/dialog/OperationDialog.tsx
+++ b/FrontEnd/src/views/common/dialog/OperationDialog.tsx
@@ -39,7 +39,7 @@ function OperationDialogPrompt(props: OperationDialogPromptProps) {
 
 export interface OperationDialogProps<TData> {
   open: boolean;
-  close: () => void;
+  onClose: () => void;
 
   color?: ThemeColor;
   inputColor?: ThemeColor;
@@ -57,7 +57,7 @@ export interface OperationDialogProps<TData> {
 function OperationDialog<TData>(props: OperationDialogProps<TData>) {
   const {
     open,
-    close,
+    onClose,
     color,
     inputColor,
     title,
@@ -85,13 +85,14 @@ function OperationDialog<TData>(props: OperationDialogProps<TData>) {
 
   const [step, setStep] = useState<Step>({ type: "input" });
 
-  const { inputGroupProps, hasError, setAllDisabled, confirm } = useInputs({
-    init: inputs,
-  });
+  const { inputGroupProps, hasErrorAndDirty, setAllDisabled, confirm } =
+    useInputs({
+      init: inputs,
+    });
 
-  function onClose() {
+  function close() {
     if (step.type !== "process") {
-      close();
+      onClose();
       if (step.type === "success" && onSuccessAndClose) {
         onSuccessAndClose?.(step.data);
       }
@@ -142,12 +143,13 @@ function OperationDialog<TData>(props: OperationDialogProps<TData>) {
             text="operationDialog.cancel"
             color="secondary"
             outline
-            onClick={onClose}
+            onClick={close}
             disabled={isProcessing}
           />
           <LoadingButton
             color={color}
             loading={isProcessing}
+            disabled={hasErrorAndDirty}
             onClick={onConfirm}
           >
             {c("operationDialog.confirm")}
@@ -173,14 +175,14 @@ function OperationDialog<TData>(props: OperationDialogProps<TData>) {
         <OperationDialogPrompt {...promptProps} />
         <hr />
         <div className="cru-dialog-bottom-area">
-          <Button text="operationDialog.ok" color="primary" onClick={onClose} />
+          <Button text="operationDialog.ok" color="primary" onClick={close} />
         </div>
       </div>
     );
   }
 
   return (
-    <Dialog open={open} onClose={onClose}>
+    <Dialog open={open} onClose={close}>
       <div
         className={classNames(
           "cru-operation-dialog-container",
diff --git a/FrontEnd/src/views/common/dialog/index.ts b/FrontEnd/src/views/common/dialog/index.ts
new file mode 100644
index 00000000..e37b9ed2
--- /dev/null
+++ b/FrontEnd/src/views/common/dialog/index.ts
@@ -0,0 +1,56 @@
+import { useState } from "react";
+
+export { default as Dialog } from "./Dialog";
+export { default as FullPageDialog } from "./FullPageDialog";
+export { default as OperationDialog } from "./OperationDialog";
+export { default as ConfirmDialog } from "./ConfirmDialog";
+
+type DialogMap<D extends string, V> = {
+  [K in D]: V;
+};
+
+type DialogKeyMap<D extends string> = DialogMap<D, number>;
+
+type DialogPropsMap<D extends string> = DialogMap<
+  D,
+  { key: number | string; open: boolean; onClose: () => void }
+>;
+
+export function useDialog<D extends string>(
+  dialogs: D[],
+  initDialog?: D | null,
+): {
+  dialog: D | null;
+  switchDialog: (newDialog: D | null) => void;
+  dialogPropsMap: DialogPropsMap<D>;
+  createDialogSwitch: (newDialog: D | null) => () => void;
+} {
+  const [dialog, setDialog] = useState<D | null>(initDialog ?? null);
+
+  const [dialogKeys, setDialogKeys] = useState<DialogKeyMap<D>>(
+    () => Object.fromEntries(dialogs.map((d) => [d, 0])) as DialogKeyMap<D>,
+  );
+
+  const switchDialog = (newDialog: D | null) => {
+    if (dialog !== null) {
+      setDialogKeys({ ...dialogKeys, [dialog]: dialogKeys[dialog] + 1 });
+    }
+    setDialog(newDialog);
+  };
+
+  return {
+    dialog,
+    switchDialog,
+    dialogPropsMap: Object.fromEntries(
+      dialogs.map((d) => [
+        d,
+        {
+          key: `${d}-${dialogKeys[d]}`,
+          open: dialog === d,
+          onClose: () => switchDialog(null),
+        },
+      ]),
+    ) as DialogPropsMap<D>,
+    createDialogSwitch: (newDialog: D | null) => () => switchDialog(newDialog),
+  };
+}
diff --git a/FrontEnd/src/views/common/input/InputGroup.tsx b/FrontEnd/src/views/common/input/InputGroup.tsx
index 858fa1a5..3d1e3ada 100644
--- a/FrontEnd/src/views/common/input/InputGroup.tsx
+++ b/FrontEnd/src/views/common/input/InputGroup.tsx
@@ -141,6 +141,7 @@ export type ConfirmResult =
 export function useInputs(options: { init: Initializer }): {
   inputGroupProps: InputGroupProps;
   hasError: boolean;
+  hasErrorAndDirty: boolean;
   confirm: () => ConfirmResult;
   setAllDisabled: (disabled: boolean) => void;
 } {
@@ -260,6 +261,9 @@ export function useInputs(options: { init: Initializer }): {
     componentInputs.push(componentInput);
   }
 
+  const hasError = Object.keys(data.errors).length > 0;
+  const hasDirty = Object.keys(data.dirties).some((key) => data.dirties[key]);
+
   return {
     inputGroupProps: {
       inputs: componentInputs,
@@ -280,7 +284,8 @@ export function useInputs(options: { init: Initializer }): {
         });
       },
     },
-    hasError: Object.keys(data.errors).length > 0,
+    hasError,
+    hasErrorAndDirty: hasError && hasDirty,
     confirm() {
       const newDirties = createAllDirties();
       const newErrors = validator?.(data.values, scheme.inputs) ?? {};
diff --git a/FrontEnd/src/views/timeline/PostPropertyChangeDialog.tsx b/FrontEnd/src/views/timeline/PostPropertyChangeDialog.tsx
index 76f542c1..fc55185c 100644
--- a/FrontEnd/src/views/timeline/PostPropertyChangeDialog.tsx
+++ b/FrontEnd/src/views/timeline/PostPropertyChangeDialog.tsx
@@ -15,7 +15,7 @@ function PostPropertyChangeDialog(props: {
   return (
     <OperationDialog
       title="timeline.changePostPropertyDialog.title"
-      close={onClose}
+      onClose={onClose}
       open={open}
       inputScheme={[
         {
diff --git a/FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx b/FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx
index 3b5ba42f..c960b3c2 100644
--- a/FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx
+++ b/FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx
@@ -20,7 +20,7 @@ const TimelineDeleteDialog: React.FC<TimelineDeleteDialog> = (props) => {
   return (
     <OperationDialog
       open={props.open}
-      close={props.close}
+      onClose={props.close}
       title="timeline.deleteDialog.title"
       themeColor="danger"
       inputPrompt={() => {
diff --git a/FrontEnd/src/views/timeline/TimelinePropertyChangeDialog.tsx b/FrontEnd/src/views/timeline/TimelinePropertyChangeDialog.tsx
index a0eebdbb..bd5bef4c 100644
--- a/FrontEnd/src/views/timeline/TimelinePropertyChangeDialog.tsx
+++ b/FrontEnd/src/views/timeline/TimelinePropertyChangeDialog.tsx
@@ -55,7 +55,7 @@ const TimelinePropertyChangeDialog: React.FC<
         ] as const
       }
       open={props.open}
-      close={props.close}
+      onClose={props.close}
       onProcess={([newTitle, newVisibility, newDescription, newColor]) => {
         const req: HttpTimelinePatchRequest = {};
         if (newTitle !== timeline.title) {
-- 
cgit v1.2.3


From d9c1d512fa64ef4f8c08ca34f7a5842642879bcc Mon Sep 17 00:00:00 2001
From: crupest <crupest@outlook.com>
Date: Mon, 31 Jul 2023 00:08:48 +0800
Subject: ...

---
 FrontEnd/src/pages/timeline/Timeline.css           |   7 -
 FrontEnd/src/pages/timeline/Timeline.tsx           |  15 +-
 FrontEnd/src/pages/timeline/TimelineCard.css       |  18 ++
 FrontEnd/src/pages/timeline/TimelineCard.tsx       |  59 ++---
 .../src/pages/timeline/TimelineDeleteDialog.tsx    |   4 +-
 .../timeline/TimelinePropertyChangeDialog.tsx      |   4 +-
 FrontEnd/src/views/timeline/CollapseButton.tsx     |  21 --
 .../src/views/timeline/ConnectionStatusBadge.css   |  36 ---
 .../src/views/timeline/ConnectionStatusBadge.tsx   |  41 ----
 FrontEnd/src/views/timeline/MarkdownPostEdit.css   |  21 --
 FrontEnd/src/views/timeline/MarkdownPostEdit.tsx   | 215 -----------------
 .../views/timeline/PostPropertyChangeDialog.tsx    |  42 ----
 FrontEnd/src/views/timeline/Timeline.css           | 244 -------------------
 FrontEnd/src/views/timeline/Timeline.tsx           | 207 ----------------
 FrontEnd/src/views/timeline/TimelineCard.tsx       | 167 -------------
 FrontEnd/src/views/timeline/TimelineDateLabel.tsx  |  19 --
 .../src/views/timeline/TimelineDeleteDialog.tsx    |  61 -----
 FrontEnd/src/views/timeline/TimelineEmptyItem.tsx  |  25 --
 FrontEnd/src/views/timeline/TimelineLine.tsx       |  51 ----
 FrontEnd/src/views/timeline/TimelineLoading.tsx    |  16 --
 FrontEnd/src/views/timeline/TimelineMember.css     |   8 -
 FrontEnd/src/views/timeline/TimelineMember.tsx     | 202 ----------------
 .../src/views/timeline/TimelinePostContentView.tsx | 187 ---------------
 FrontEnd/src/views/timeline/TimelinePostEdit.css   |  10 -
 FrontEnd/src/views/timeline/TimelinePostEdit.tsx   | 267 ---------------------
 .../src/views/timeline/TimelinePostEditCard.tsx    |  31 ---
 .../src/views/timeline/TimelinePostEditNoLogin.tsx |  18 --
 .../src/views/timeline/TimelinePostListView.tsx    |  76 ------
 FrontEnd/src/views/timeline/TimelinePostView.tsx   | 159 ------------
 .../timeline/TimelinePropertyChangeDialog.tsx      |  82 -------
 FrontEnd/src/views/timeline/index.tsx              |  23 --
 31 files changed, 55 insertions(+), 2281 deletions(-)
 create mode 100644 FrontEnd/src/pages/timeline/TimelineCard.css
 delete mode 100644 FrontEnd/src/views/timeline/CollapseButton.tsx
 delete mode 100644 FrontEnd/src/views/timeline/ConnectionStatusBadge.css
 delete mode 100644 FrontEnd/src/views/timeline/ConnectionStatusBadge.tsx
 delete mode 100644 FrontEnd/src/views/timeline/MarkdownPostEdit.css
 delete mode 100644 FrontEnd/src/views/timeline/MarkdownPostEdit.tsx
 delete mode 100644 FrontEnd/src/views/timeline/PostPropertyChangeDialog.tsx
 delete mode 100644 FrontEnd/src/views/timeline/Timeline.css
 delete mode 100644 FrontEnd/src/views/timeline/Timeline.tsx
 delete mode 100644 FrontEnd/src/views/timeline/TimelineCard.tsx
 delete mode 100644 FrontEnd/src/views/timeline/TimelineDateLabel.tsx
 delete mode 100644 FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx
 delete mode 100644 FrontEnd/src/views/timeline/TimelineEmptyItem.tsx
 delete mode 100644 FrontEnd/src/views/timeline/TimelineLine.tsx
 delete mode 100644 FrontEnd/src/views/timeline/TimelineLoading.tsx
 delete mode 100644 FrontEnd/src/views/timeline/TimelineMember.css
 delete mode 100644 FrontEnd/src/views/timeline/TimelineMember.tsx
 delete mode 100644 FrontEnd/src/views/timeline/TimelinePostContentView.tsx
 delete mode 100644 FrontEnd/src/views/timeline/TimelinePostEdit.css
 delete mode 100644 FrontEnd/src/views/timeline/TimelinePostEdit.tsx
 delete mode 100644 FrontEnd/src/views/timeline/TimelinePostEditCard.tsx
 delete mode 100644 FrontEnd/src/views/timeline/TimelinePostEditNoLogin.tsx
 delete mode 100644 FrontEnd/src/views/timeline/TimelinePostListView.tsx
 delete mode 100644 FrontEnd/src/views/timeline/TimelinePostView.tsx
 delete mode 100644 FrontEnd/src/views/timeline/TimelinePropertyChangeDialog.tsx
 delete mode 100644 FrontEnd/src/views/timeline/index.tsx

(limited to 'FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx')

diff --git a/FrontEnd/src/pages/timeline/Timeline.css b/FrontEnd/src/pages/timeline/Timeline.css
index 4dd4fdcc..f071f163 100644
--- a/FrontEnd/src/pages/timeline/Timeline.css
+++ b/FrontEnd/src/pages/timeline/Timeline.css
@@ -230,13 +230,6 @@
   margin-right: 0.6em;
 }
 
-.timeline-card {
-  position: fixed;
-  z-index: 1029;
-  top: 56px;
-  right: 0;
-  margin: 0.5em;
-}
 
 .timeline-top {
   position: sticky;
diff --git a/FrontEnd/src/pages/timeline/Timeline.tsx b/FrontEnd/src/pages/timeline/Timeline.tsx
index 3a7fbd00..f93e1623 100644
--- a/FrontEnd/src/pages/timeline/Timeline.tsx
+++ b/FrontEnd/src/pages/timeline/Timeline.tsx
@@ -41,7 +41,7 @@ const Timeline: React.FC<TimelineProps> = (props) => {
   const [timeline, setTimeline] = React.useState<HttpTimelineInfo | null>(null);
   const [posts, setPosts] = React.useState<HttpTimelinePostInfo[] | null>(null);
   const [signalrState, setSignalrState] = React.useState<HubConnectionState>(
-    HubConnectionState.Connecting
+    HubConnectionState.Connecting,
   );
   const [error, setError] = React.useState<
     "offline" | "forbid" | "notfound" | "error" | null
@@ -81,7 +81,7 @@ const Timeline: React.FC<TimelineProps> = (props) => {
             console.error(error);
             setError("error");
           }
-        }
+        },
       );
   }, [timelineOwner, timelineName, timelineReloadKey]);
 
@@ -91,7 +91,7 @@ const Timeline: React.FC<TimelineProps> = (props) => {
       .then(
         (page) => {
           setPosts(
-            page.items.filter((p): p is HttpTimelinePostInfo => !p.deleted)
+            page.items.filter((p): p is HttpTimelinePostInfo => !p.deleted),
           );
           setTotalPage(page.totalPageCount);
         },
@@ -106,14 +106,14 @@ const Timeline: React.FC<TimelineProps> = (props) => {
             console.error(error);
             setError("error");
           }
-        }
+        },
       );
   }, [timelineOwner, timelineName, postsReloadKey]);
 
   React.useEffect(() => {
     const timelinePostUpdate$ = getTimelinePostUpdate$(
       timelineOwner,
-      timelineName
+      timelineName,
     );
     const subscription = timelinePostUpdate$.subscribe(({ update, state }) => {
       if (update) {
@@ -134,7 +134,7 @@ const Timeline: React.FC<TimelineProps> = (props) => {
       .then(
         (page) => {
           const ps = page.items.filter(
-            (p): p is HttpTimelinePostInfo => !p.deleted
+            (p): p is HttpTimelinePostInfo => !p.deleted,
           );
           setPosts((old) => [...(old ?? []), ...ps]);
         },
@@ -149,7 +149,7 @@ const Timeline: React.FC<TimelineProps> = (props) => {
             console.error(error);
             setError("error");
           }
-        }
+        },
       );
   }, currentPage < totalPage);
 
@@ -183,7 +183,6 @@ const Timeline: React.FC<TimelineProps> = (props) => {
       {timeline == null && posts == null && <TimelineLoading />}
       {timeline && (
         <TimelineCard
-          className="timeline-card"
           timeline={timeline}
           connectionStatus={signalrState}
           onReload={updateTimeline}
diff --git a/FrontEnd/src/pages/timeline/TimelineCard.css b/FrontEnd/src/pages/timeline/TimelineCard.css
new file mode 100644
index 00000000..75ce6c51
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelineCard.css
@@ -0,0 +1,18 @@
+.timeline-card {
+  position: fixed;
+  z-index: 1029;
+  top: 56px;
+  right: 0;
+  margin: 0.5em;
+}
+
+.timeline-card-title {
+  display: inline-block;
+  vertical-align: middle;
+  color: var(--cru-key-on-color);
+}
+
+.timeline-card-title-name {
+  margin-inline-start: 1em;
+  color: var(--cru-surface-on-color);
+}
\ No newline at end of file
diff --git a/FrontEnd/src/pages/timeline/TimelineCard.tsx b/FrontEnd/src/pages/timeline/TimelineCard.tsx
index 8ce133c0..bcdfa4c2 100644
--- a/FrontEnd/src/pages/timeline/TimelineCard.tsx
+++ b/FrontEnd/src/pages/timeline/TimelineCard.tsx
@@ -1,5 +1,4 @@
-import * as React from "react";
-import { useTranslation } from "react-i18next";
+import { useState } from "react";
 import classnames from "classnames";
 import { HubConnectionState } from "@microsoft/signalr";
 
@@ -10,6 +9,8 @@ import { pushAlert } from "@/services/alert";
 import { HttpTimelineInfo } from "@/http/timeline";
 import { getHttpBookmarkClient } from "@/http/bookmark";
 
+import { useC } from "@/views/common/common";
+import { useDialog } from "@/views/common/dialog";
 import UserAvatar from "@/views/common/user/UserAvatar";
 import PopupMenu from "@/views/common/menu/PopupMenu";
 import FullPageDialog from "@/views/common/dialog/FullPageDialog";
@@ -21,36 +22,36 @@ import { TimelineMemberDialog } from "./TimelineMember";
 import TimelinePropertyChangeDialog from "./TimelinePropertyChangeDialog";
 import IconButton from "@/views/common/button/IconButton";
 
-export interface TimelinePageCardProps {
+import "./TimelineCard.css";
+
+interface TimelinePageCardProps {
   timeline: HttpTimelineInfo;
   connectionStatus: HubConnectionState;
-  className?: string;
   onReload: () => void;
 }
 
-const TimelineCard: React.FC<TimelinePageCardProps> = (props) => {
-  const { timeline, connectionStatus, onReload, className } = props;
+export default function TimelineCard(props: TimelinePageCardProps) {
+  const { timeline, connectionStatus, onReload } = props;
 
-  const { t } = useTranslation();
+  const user = useUser();
 
-  const [dialog, setDialog] = React.useState<
-    "member" | "property" | "delete" | null
-  >(null);
+  const c = useC();
 
-  const [collapse, setCollapse] = React.useState(true);
+  const [collapse, setCollapse] = useState(true);
   const toggleCollapse = (): void => {
     setCollapse((o) => !o);
   };
 
   const isSmallScreen = useIsSmallScreen();
 
-  const user = useUser();
+  const { createDialogSwitch, dialog, dialogPropsMap, switchDialog } =
+    useDialog(["member", "property", "delete"]);
 
   const content = (
-    <>
-      <h3 className="cru-color-primary d-inline-block align-middle">
+    <div className="cru-primary">
+      <h3 className="timeline-card-title">
         {timeline.title}
-        <small className="ms-3 cru-color-secondary">{timeline.nameV2}</small>
+        <small className="timeline-card-title-name">{timeline.nameV2}</small>
       </h3>
       <div>
         <UserAvatar
@@ -64,7 +65,7 @@ const TimelineCard: React.FC<TimelinePageCardProps> = (props) => {
       </div>
       <p className="mb-0">{timeline.description}</p>
       <small className="mt-1 d-block">
-        {t(timelineVisibilityTooltipTranslationMap[timeline.visibility])}
+        {c(timelineVisibilityTooltipTranslationMap[timeline.visibility])}
       </small>
       <div className="mt-2 cru-text-end">
         {user != null ? (
@@ -92,7 +93,7 @@ const TimelineCard: React.FC<TimelinePageCardProps> = (props) => {
         <IconButton
           icon="people"
           className="me-3"
-          onClick={() => setDialog("member")}
+          onClick={createDialogSwitch("member")}
         />
         {timeline.manageable ? (
           <PopupMenu
@@ -100,12 +101,12 @@ const TimelineCard: React.FC<TimelinePageCardProps> = (props) => {
               {
                 type: "button",
                 text: "timeline.manageItem.property",
-                onClick: () => setDialog("property"),
+                onClick: createDialogSwitch("property"),
               },
               { type: "divider" },
               {
                 type: "button",
-                onClick: () => setDialog("delete"),
+                onClick: createDialogSwitch("delete"),
                 color: "danger",
                 text: "timeline.manageItem.delete",
               },
@@ -116,12 +117,12 @@ const TimelineCard: React.FC<TimelinePageCardProps> = (props) => {
           </PopupMenu>
         ) : null}
       </div>
-    </>
+    </div>
   );
 
   return (
     <>
-      <Card className={classnames("p-2 cru-clearfix", className)}>
+      <Card className="timeline-card">
         <div
           className={classnames(
             "cru-float-right d-flex align-items-center",
@@ -145,23 +146,15 @@ const TimelineCard: React.FC<TimelinePageCardProps> = (props) => {
       </Card>
       <TimelineMemberDialog
         timeline={timeline}
-        onClose={() => setDialog(null)}
-        open={dialog === "member"}
         onChange={onReload}
+        {...dialogPropsMap["member"]}
       />
       <TimelinePropertyChangeDialog
         timeline={timeline}
-        close={() => setDialog(null)}
-        open={dialog === "property"}
         onChange={onReload}
+        {...dialogPropsMap["property"]}
       />
-      <TimelineDeleteDialog
-        timeline={timeline}
-        open={dialog === "delete"}
-        close={() => setDialog(null)}
-      />
+      <TimelineDeleteDialog timeline={timeline} {...dialogPropsMap["delete"]} />
     </>
   );
-};
-
-export default TimelineCard;
+}
diff --git a/FrontEnd/src/pages/timeline/TimelineDeleteDialog.tsx b/FrontEnd/src/pages/timeline/TimelineDeleteDialog.tsx
index d5b22aee..0a5a2491 100644
--- a/FrontEnd/src/pages/timeline/TimelineDeleteDialog.tsx
+++ b/FrontEnd/src/pages/timeline/TimelineDeleteDialog.tsx
@@ -9,7 +9,7 @@ import OperationDialog from "@/views/common/dialog/OperationDialog";
 interface TimelineDeleteDialog {
   timeline: HttpTimelineInfo;
   open: boolean;
-  close: () => void;
+  onClose: () => void;
 }
 
 const TimelineDeleteDialog: React.FC<TimelineDeleteDialog> = (props) => {
@@ -20,7 +20,7 @@ const TimelineDeleteDialog: React.FC<TimelineDeleteDialog> = (props) => {
   return (
     <OperationDialog
       open={props.open}
-      onClose={props.close}
+      onClose={props.onClose}
       title="timeline.deleteDialog.title"
       color="danger"
       inputPrompt={() => (
diff --git a/FrontEnd/src/pages/timeline/TimelinePropertyChangeDialog.tsx b/FrontEnd/src/pages/timeline/TimelinePropertyChangeDialog.tsx
index e26df3eb..b57135bb 100644
--- a/FrontEnd/src/pages/timeline/TimelinePropertyChangeDialog.tsx
+++ b/FrontEnd/src/pages/timeline/TimelinePropertyChangeDialog.tsx
@@ -12,7 +12,7 @@ import OperationDialog from "@/views/common/dialog/OperationDialog";
 
 export interface TimelinePropertyChangeDialogProps {
   open: boolean;
-  close: () => void;
+  onClose: () => void;
   timeline: HttpTimelineInfo;
   onChange: () => void;
 }
@@ -64,7 +64,7 @@ const TimelinePropertyChangeDialog: React.FC<
         },
       }}
       open={props.open}
-      onClose={props.close}
+      onClose={props.onClose}
       onProcess={({ title, visibility, description }) => {
         const req: HttpTimelinePatchRequest = {};
         if (title !== timeline.title) {
diff --git a/FrontEnd/src/views/timeline/CollapseButton.tsx b/FrontEnd/src/views/timeline/CollapseButton.tsx
deleted file mode 100644
index 374ccc2e..00000000
--- a/FrontEnd/src/views/timeline/CollapseButton.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import * as React from "react";
-
-import IconButton from "../common/button/IconButton";
-
-const CollapseButton: React.FC<{
-  collapse: boolean;
-  onClick: () => void;
-  className?: string;
-  style?: React.CSSProperties;
-}> = ({ collapse, onClick, className, style }) => {
-  return (
-    <IconButton
-      icon={collapse ? "arrows-angle-expand" : "arrows-angle-contract"}
-      onClick={onClick}
-      className={className}
-      style={style}
-    />
-  );
-};
-
-export default CollapseButton;
diff --git a/FrontEnd/src/views/timeline/ConnectionStatusBadge.css b/FrontEnd/src/views/timeline/ConnectionStatusBadge.css
deleted file mode 100644
index 7fe83b9b..00000000
--- a/FrontEnd/src/views/timeline/ConnectionStatusBadge.css
+++ /dev/null
@@ -1,36 +0,0 @@
-.connection-status-badge {
-  font-size: 0.8em;
-  border-radius: 5px;
-  padding: 0.1em 1em;
-  background-color: #eaf2ff;
-}
-.connection-status-badge::before {
-  width: 10px;
-  height: 10px;
-  border-radius: 50%;
-  display: inline-block;
-  content: "";
-  margin-right: 0.6em;
-}
-.connection-status-badge.success {
-  color: #006100;
-}
-.connection-status-badge.success::before {
-  background-color: #006100;
-}
-
-.connection-status-badge.warning {
-  color: #e4a700;
-}
-
-.connection-status-badge.warning::before {
-  background-color: #e4a700;
-}
-
-.connection-status-badge.danger {
-  color: #fd1616;
-}
-
-.connection-status-badge.danger::before {
-  background-color: #fd1616;
-}
diff --git a/FrontEnd/src/views/timeline/ConnectionStatusBadge.tsx b/FrontEnd/src/views/timeline/ConnectionStatusBadge.tsx
deleted file mode 100644
index 2b820454..00000000
--- a/FrontEnd/src/views/timeline/ConnectionStatusBadge.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-import * as React from "react";
-import classnames from "classnames";
-import { HubConnectionState } from "@microsoft/signalr";
-import { useTranslation } from "react-i18next";
-
-import "./ConnectionStatusBadge.css";
-
-export interface ConnectionStatusBadgeProps {
-  status: HubConnectionState;
-  className?: string;
-  style?: React.CSSProperties;
-}
-
-const classNameMap: Record<HubConnectionState, string> = {
-  Connected: "success",
-  Connecting: "warning",
-  Disconnected: "danger",
-  Disconnecting: "warning",
-  Reconnecting: "warning",
-};
-
-const ConnectionStatusBadge: React.FC<ConnectionStatusBadgeProps> = (props) => {
-  const { status, className, style } = props;
-
-  const { t } = useTranslation();
-
-  return (
-    <div
-      className={classnames(
-        "connection-status-badge",
-        classNameMap[status],
-        className
-      )}
-      style={style}
-    >
-      {t(`connectionState.${status}`)}
-    </div>
-  );
-};
-
-export default ConnectionStatusBadge;
diff --git a/FrontEnd/src/views/timeline/MarkdownPostEdit.css b/FrontEnd/src/views/timeline/MarkdownPostEdit.css
deleted file mode 100644
index e36be992..00000000
--- a/FrontEnd/src/views/timeline/MarkdownPostEdit.css
+++ /dev/null
@@ -1,21 +0,0 @@
-.timeline-markdown-post-edit-page {
-  overflow: auto;
-  max-height: 300px;
-}
-
-.timeline-markdown-post-edit-image-container {
-  position: relative;
-  text-align: center;
-  margin-bottom: 1em;
-}
-
-.timeline-markdown-post-edit-image {
-  max-width: 100%;
-  max-height: 200px;
-}
-
-.timeline-markdown-post-edit-image-delete-button {
-  position: absolute;
-  right: 10px;
-  top: 2px;
-}
diff --git a/FrontEnd/src/views/timeline/MarkdownPostEdit.tsx b/FrontEnd/src/views/timeline/MarkdownPostEdit.tsx
deleted file mode 100644
index 6401cfaa..00000000
--- a/FrontEnd/src/views/timeline/MarkdownPostEdit.tsx
+++ /dev/null
@@ -1,215 +0,0 @@
-import * as React from "react";
-import classnames from "classnames";
-import { useTranslation } from "react-i18next";
-
-import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline";
-
-import TimelinePostBuilder from "@/services/TimelinePostBuilder";
-
-import FlatButton from "../common/button/FlatButton";
-import TabPages from "../common/tab/TabPages";
-import ConfirmDialog from "../common/dialog/ConfirmDialog";
-import Spinner from "../common/Spinner";
-import IconButton from "../common/button/IconButton";
-
-import "./MarkdownPostEdit.css";
-
-export interface MarkdownPostEditProps {
-  owner: string;
-  timeline: string;
-  onPosted: (post: HttpTimelinePostInfo) => void;
-  onPostError: () => void;
-  onClose: () => void;
-  className?: string;
-  style?: React.CSSProperties;
-}
-
-const MarkdownPostEdit: React.FC<MarkdownPostEditProps> = ({
-  owner: ownerUsername,
-  timeline: timelineName,
-  onPosted,
-  onClose,
-  onPostError,
-  className,
-  style,
-}) => {
-  const { t } = useTranslation();
-
-  const [canLeave, setCanLeave] = React.useState<boolean>(true);
-
-  const [process, setProcess] = React.useState<boolean>(false);
-
-  const [showLeaveConfirmDialog, setShowLeaveConfirmDialog] =
-    React.useState<boolean>(false);
-
-  const [text, _setText] = React.useState<string>("");
-  const [images, _setImages] = React.useState<{ file: File; url: string }[]>(
-    []
-  );
-  const [previewHtml, _setPreviewHtml] = React.useState<string>("");
-
-  const _builder = React.useRef<TimelinePostBuilder | null>(null);
-
-  const getBuilder = (): TimelinePostBuilder => {
-    if (_builder.current == null) {
-      const builder = new TimelinePostBuilder(() => {
-        setCanLeave(builder.isEmpty);
-        _setText(builder.text);
-        _setImages(builder.images);
-        _setPreviewHtml(builder.renderHtml());
-      });
-      _builder.current = builder;
-    }
-    return _builder.current;
-  };
-
-  const canSend = text.length > 0;
-
-  React.useEffect(() => {
-    return () => {
-      getBuilder().dispose();
-    };
-  }, []);
-
-  React.useEffect(() => {
-    window.onbeforeunload = (): unknown => {
-      if (!canLeave) {
-        return t("timeline.confirmLeave");
-      }
-    };
-
-    return () => {
-      window.onbeforeunload = null;
-    };
-  }, [canLeave, t]);
-
-  const send = async (): Promise<void> => {
-    setProcess(true);
-    try {
-      const dataList = await getBuilder().build();
-      const post = await getHttpTimelineClient().postPost(
-        ownerUsername,
-        timelineName,
-        {
-          dataList,
-        }
-      );
-      onPosted(post);
-      onClose();
-    } catch (e) {
-      setProcess(false);
-      onPostError();
-    }
-  };
-
-  return (
-    <>
-      <TabPages
-        className={className}
-        style={style}
-        pageContainerClassName="py-2"
-        dense
-        actions={
-          process ? (
-            <Spinner />
-          ) : (
-            <div>
-              <IconButton
-                icon="x"
-                color="danger"
-                large
-                className="cru-align-middle me-2"
-                onClick={() => {
-                  if (canLeave) {
-                    onClose();
-                  } else {
-                    setShowLeaveConfirmDialog(true);
-                  }
-                }}
-              />
-              {canSend && (
-                <FlatButton text="timeline.send" onClick={() => void send()} />
-              )}
-            </div>
-          )
-        }
-        pages={[
-          {
-            name: "text",
-            text: "edit",
-            page: (
-              <textarea
-                value={text}
-                disabled={process}
-                className="cru-fill-parent"
-                onChange={(event) => {
-                  getBuilder().setMarkdownText(event.currentTarget.value);
-                }}
-              />
-            ),
-          },
-          {
-            name: "images",
-            text: "image",
-            page: (
-              <div className="timeline-markdown-post-edit-page">
-                {images.map((image, index) => (
-                  <div
-                    key={image.url}
-                    className="timeline-markdown-post-edit-image-container"
-                  >
-                    <img
-                      src={image.url}
-                      className="timeline-markdown-post-edit-image"
-                    />
-                    <IconButton
-                      icon="trash"
-                      color="danger"
-                      className={classnames(
-                        "timeline-markdown-post-edit-image-delete-button",
-                        process && "d-none"
-                      )}
-                      onClick={() => {
-                        getBuilder().deleteImage(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) {
-                      getBuilder().appendImage(files[0]);
-                    }
-                  }}
-                  disabled={process}
-                />
-              </div>
-            ),
-          },
-          {
-            name: "preview",
-            text: "preview",
-            page: (
-              <div
-                className="markdown-container timeline-markdown-post-edit-page"
-                dangerouslySetInnerHTML={{ __html: previewHtml }}
-              />
-            ),
-          },
-        ]}
-      />
-      <ConfirmDialog
-        onClose={() => setShowLeaveConfirmDialog(false)}
-        onConfirm={onClose}
-        open={showLeaveConfirmDialog}
-        title="timeline.dropDraft"
-        body="timeline.confirmLeave"
-      />
-    </>
-  );
-};
-
-export default MarkdownPostEdit;
diff --git a/FrontEnd/src/views/timeline/PostPropertyChangeDialog.tsx b/FrontEnd/src/views/timeline/PostPropertyChangeDialog.tsx
deleted file mode 100644
index fc55185c..00000000
--- a/FrontEnd/src/views/timeline/PostPropertyChangeDialog.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import * as React from "react";
-
-import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline";
-
-import OperationDialog from "../common/dialog/OperationDialog";
-
-function PostPropertyChangeDialog(props: {
-  open: boolean;
-  onClose: () => void;
-  post: HttpTimelinePostInfo;
-  onSuccess: (post: HttpTimelinePostInfo) => void;
-}): React.ReactElement | null {
-  const { open, onClose, post, onSuccess } = props;
-
-  return (
-    <OperationDialog
-      title="timeline.changePostPropertyDialog.title"
-      onClose={onClose}
-      open={open}
-      inputScheme={[
-        {
-          label: "timeline.changePostPropertyDialog.time",
-          type: "datetime",
-          initValue: post.time,
-        },
-      ]}
-      onProcess={([time]) => {
-        return getHttpTimelineClient().patchPost(
-          post.timelineOwnerV2,
-          post.timelineNameV2,
-          post.id,
-          {
-            time: time === "" ? undefined : new Date(time).toISOString(),
-          }
-        );
-      }}
-      onSuccessAndClose={onSuccess}
-    />
-  );
-}
-
-export default PostPropertyChangeDialog;
diff --git a/FrontEnd/src/views/timeline/Timeline.css b/FrontEnd/src/views/timeline/Timeline.css
deleted file mode 100644
index 4dd4fdcc..00000000
--- a/FrontEnd/src/views/timeline/Timeline.css
+++ /dev/null
@@ -1,244 +0,0 @@
-.timeline {
-  z-index: 0;
-  position: relative;
-  width: 100%;
-}
-
-@keyframes timeline-line-node {
-  to {
-    box-shadow: 0 0 20px 3px var(--cru-primary-l1-color);
-  }
-}
-
-@keyframes timeline-line-node-current {
-  to {
-    box-shadow: 0 0 20px 3px var(--cru-primary-enhance-l1-color);
-  }
-}
-
-@keyframes timeline-line-node-loading {
-  to {
-    box-shadow: 0 0 20px 3px var(--cru-primary-l1-color);
-  }
-}
-
-@keyframes timeline-line-node-loading-edge {
-  from {
-    transform: rotate(0turn);
-  }
-  to {
-    transform: rotate(1turn);
-  }
-}
-
-@keyframes timeline-top-loading-enter {
-  from {
-    transform: translate(0, -100%);
-  }
-}
-
-@keyframes timeline-post-enter {
-  from {
-    transform: translate(0, 100%);
-    opacity: 0;
-  }
-  to {
-    opacity: 1;
-  }
-}
-
-.timeline-top-loading-enter {
-  animation: 1s timeline-top-loading-enter;
-}
-
-.timeline-line {
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  width: 30px;
-  position: absolute;
-  z-index: 1;
-  left: 2em;
-  top: 0;
-  bottom: 0;
-  transition: left 0.5s;
-}
-
-@media (max-width: 575.98px) {
-  .timeline-line {
-    left: 1em;
-  }
-}
-
-.timeline-line .segment {
-  width: 7px;
-  background: var(--cru-primary-color);
-}
-.timeline-line .segment.start {
-  height: 1.8em;
-  flex: 0 0 auto;
-}
-.timeline-line .segment.end {
-  flex: 1 1 auto;
-}
-.timeline-line .segment.current-end {
-  height: 2em;
-  flex: 0 0 auto;
-  background: linear-gradient(var(--cru-primary-enhance-color), white);
-}
-.timeline-line .node-container {
-  flex: 0 0 auto;
-  position: relative;
-  width: 18px;
-  height: 18px;
-}
-.timeline-line .node {
-  width: 20px;
-  height: 20px;
-  position: absolute;
-  background: var(--cru-primary-color);
-  left: -1px;
-  top: -1px;
-  border-radius: 50%;
-  box-sizing: border-box;
-  z-index: 1;
-  animation: 1s infinite alternate;
-  animation-name: timeline-line-node;
-}
-.timeline-line .node-loading-edge {
-  color: var(--cru-primary-color);
-  width: 38px;
-  height: 38px;
-  position: absolute;
-  left: -10px;
-  top: -10px;
-  box-sizing: border-box;
-  z-index: 2;
-  animation: 1.5s linear infinite timeline-line-node-loading-edge;
-}
-.timeline-line.current .segment.start {
-  background: linear-gradient(
-    var(--cru-primary-color),
-    var(--cru-primary-enhance-color)
-  );
-}
-
-.timeline-line.current .segment.end {
-  background: var(--cru-primary-enhance-color);
-}
-
-.timeline-line.current .node {
-  background: var(--cru-primary-enhance-color);
-  animation-name: timeline-line-node-current;
-}
-
-.timeline-line.loading .node {
-  background: var(--cru-primary-color);
-  animation-name: timeline-line-node-loading;
-}
-
-.timeline-item {
-  position: relative;
-  padding: 0.5em;
-}
-
-.timeline-item-card {
-  position: relative;
-  padding: 0.5em 0.5em 0.5em 4em;
-}
-
-.timeline-item-card.enter-animation {
-  animation: 0.6s forwards;
-  opacity: 0;
-}
-
-@media (max-width: 575.98px) {
-  .timeline-item-card {
-    padding-left: 3em;
-  }
-}
-
-.timeline-item-header {
-  display: flex;
-  align-items: center;
-}
-
-.timeline-avatar {
-  border-radius: 50%;
-  width: 2em;
-  height: 2em;
-}
-
-.timeline-item-delete-button {
-  position: absolute;
-  right: 0;
-  bottom: 0;
-}
-
-.timeline-content {
-  white-space: pre-line;
-}
-
-.timeline-content-image {
-  max-width: 80%;
-  max-height: 200px;
-}
-
-.timeline-date-item {
-  position: relative;
-  padding: 0.3em 0 0.3em 4em;
-}
-
-.timeline-date-item-badge {
-  display: inline-block;
-  padding: 0.1em 0.4em;
-  border-radius: 0.4em;
-  background: #7c7c7c;
-  color: white;
-  font-size: 0.8em;
-}
-
-.timeline-post-item-options-mask {
-  background: rgba(255, 255, 255, 0.85);
-  z-index: 100;
-  position: absolute;
-  left: 0;
-  right: 0;
-  top: 0;
-  bottom: 0;
-
-  display: flex;
-  justify-content: space-around;
-  align-items: center;
-
-  border-radius: var(--cru-card-border-radius);
-}
-
-.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;
-}
-
-.timeline-card {
-  position: fixed;
-  z-index: 1029;
-  top: 56px;
-  right: 0;
-  margin: 0.5em;
-}
-
-.timeline-top {
-  position: sticky;
-  top: 56px;
-}
diff --git a/FrontEnd/src/views/timeline/Timeline.tsx b/FrontEnd/src/views/timeline/Timeline.tsx
deleted file mode 100644
index 3a7fbd00..00000000
--- a/FrontEnd/src/views/timeline/Timeline.tsx
+++ /dev/null
@@ -1,207 +0,0 @@
-import * as React from "react";
-import classnames from "classnames";
-import { useScrollToBottom } from "@/utilities/hooks";
-import { HubConnectionState } from "@microsoft/signalr";
-
-import {
-  HttpForbiddenError,
-  HttpNetworkError,
-  HttpNotFoundError,
-} from "@/http/common";
-import {
-  getHttpTimelineClient,
-  HttpTimelineInfo,
-  HttpTimelinePostInfo,
-} from "@/http/timeline";
-
-import { useUser } from "@/services/user";
-import { getTimelinePostUpdate$ } from "@/services/timeline";
-
-import TimelinePostListView from "./TimelinePostListView";
-import TimelineEmptyItem from "./TimelineEmptyItem";
-import TimelineLoading from "./TimelineLoading";
-import TimelinePostEdit from "./TimelinePostEdit";
-import TimelinePostEditNoLogin from "./TimelinePostEditNoLogin";
-import TimelineCard from "./TimelineCard";
-
-import "./Timeline.css";
-
-export interface TimelineProps {
-  className?: string;
-  style?: React.CSSProperties;
-  timelineOwner: string;
-  timelineName: string;
-}
-
-const Timeline: React.FC<TimelineProps> = (props) => {
-  const { timelineOwner, timelineName, className, style } = props;
-
-  const user = useUser();
-
-  const [timeline, setTimeline] = React.useState<HttpTimelineInfo | null>(null);
-  const [posts, setPosts] = React.useState<HttpTimelinePostInfo[] | null>(null);
-  const [signalrState, setSignalrState] = React.useState<HubConnectionState>(
-    HubConnectionState.Connecting
-  );
-  const [error, setError] = React.useState<
-    "offline" | "forbid" | "notfound" | "error" | null
-  >(null);
-
-  const [currentPage, setCurrentPage] = React.useState(1);
-  const [totalPage, setTotalPage] = React.useState(0);
-
-  const [timelineReloadKey, setTimelineReloadKey] = React.useState(0);
-  const [postsReloadKey, setPostsReloadKey] = React.useState(0);
-
-  const updateTimeline = (): void => setTimelineReloadKey((o) => o + 1);
-  const updatePosts = (): void => setPostsReloadKey((o) => o + 1);
-
-  React.useEffect(() => {
-    setTimeline(null);
-    setPosts(null);
-    setError(null);
-    setSignalrState(HubConnectionState.Connecting);
-  }, [timelineOwner, timelineName]);
-
-  React.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]);
-
-  React.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]);
-
-  React.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} style={style}>
-        Offline.
-      </div>
-    );
-  } else if (error === "notfound") {
-    return (
-      <div className={className} style={style}>
-        Not exist.
-      </div>
-    );
-  } else if (error === "forbid") {
-    return (
-      <div className={className} style={style}>
-        Forbid.
-      </div>
-    );
-  } else if (error === "error") {
-    return (
-      <div className={className} style={style}>
-        Error.
-      </div>
-    );
-  }
-  return (
-    <>
-      {timeline == null && posts == null && <TimelineLoading />}
-      {timeline && (
-        <TimelineCard
-          className="timeline-card"
-          timeline={timeline}
-          connectionStatus={signalrState}
-          onReload={updateTimeline}
-        />
-      )}
-      {posts && (
-        <div style={style} className={classnames("timeline", className)}>
-          <TimelineEmptyItem className="timeline-top" height={50} />
-          {timeline?.postable ? (
-            <TimelinePostEdit timeline={timeline} onPosted={updatePosts} />
-          ) : user == null ? (
-            <TimelinePostEditNoLogin />
-          ) : null}
-          <TimelinePostListView posts={posts} onReload={updatePosts} />
-        </div>
-      )}
-    </>
-  );
-};
-
-export default Timeline;
diff --git a/FrontEnd/src/views/timeline/TimelineCard.tsx b/FrontEnd/src/views/timeline/TimelineCard.tsx
deleted file mode 100644
index fdf7f0a0..00000000
--- a/FrontEnd/src/views/timeline/TimelineCard.tsx
+++ /dev/null
@@ -1,167 +0,0 @@
-import * as React from "react";
-import { useTranslation } from "react-i18next";
-import classnames from "classnames";
-import { HubConnectionState } from "@microsoft/signalr";
-
-import { useIsSmallScreen } from "@/utilities/hooks";
-import { timelineVisibilityTooltipTranslationMap } from "@/services/timeline";
-import { useUser } from "@/services/user";
-import { pushAlert } from "@/services/alert";
-import { HttpTimelineInfo } from "@/http/timeline";
-import { getHttpBookmarkClient } from "@/http/bookmark";
-
-import UserAvatar from "../common/user/UserAvatar";
-import PopupMenu from "../common/menu/PopupMenu";
-import FullPageDialog from "../common/dialog/FullPageDialog";
-import Card from "../common/Card";
-import TimelineDeleteDialog from "./TimelineDeleteDialog";
-import ConnectionStatusBadge from "./ConnectionStatusBadge";
-import CollapseButton from "./CollapseButton";
-import { TimelineMemberDialog } from "./TimelineMember";
-import TimelinePropertyChangeDialog from "./TimelinePropertyChangeDialog";
-import IconButton from "../common/button/IconButton";
-
-export interface TimelinePageCardProps {
-  timeline: HttpTimelineInfo;
-  connectionStatus: HubConnectionState;
-  className?: string;
-  onReload: () => void;
-}
-
-const TimelineCard: React.FC<TimelinePageCardProps> = (props) => {
-  const { timeline, connectionStatus, onReload, className } = props;
-
-  const { t } = useTranslation();
-
-  const [dialog, setDialog] = React.useState<
-    "member" | "property" | "delete" | null
-  >(null);
-
-  const [collapse, setCollapse] = React.useState(true);
-  const toggleCollapse = (): void => {
-    setCollapse((o) => !o);
-  };
-
-  const isSmallScreen = useIsSmallScreen();
-
-  const user = useUser();
-
-  const content = (
-    <>
-      <h3 className="cru-color-primary d-inline-block align-middle">
-        {timeline.title}
-        <small className="ms-3 cru-color-secondary">{timeline.nameV2}</small>
-      </h3>
-      <div>
-        <UserAvatar
-          username={timeline.owner.username}
-          className="cru-avatar small cru-round me-3"
-        />
-        {timeline.owner.nickname}
-        <small className="ms-3 cru-color-secondary">
-          @{timeline.owner.username}
-        </small>
-      </div>
-      <p className="mb-0">{timeline.description}</p>
-      <small className="mt-1 d-block">
-        {t(timelineVisibilityTooltipTranslationMap[timeline.visibility])}
-      </small>
-      <div className="mt-2 cru-text-end">
-        {user != null ? (
-          <IconButton
-            icon={timeline.isBookmark ? "bookmark-fill" : "bookmark"}
-            className="me-3"
-            onClick={() => {
-              getHttpBookmarkClient()
-                [timeline.isBookmark ? "delete" : "post"](
-                  user.username,
-                  timeline.owner.username,
-                  timeline.nameV2
-                )
-                .then(onReload, () => {
-                  pushAlert({
-                    message: timeline.isBookmark
-                      ? "timeline.removeBookmarkFail"
-                      : "timeline.addBookmarkFail",
-                    type: "danger",
-                  });
-                });
-            }}
-          />
-        ) : null}
-        <IconButton
-          icon="people"
-          className="me-3"
-          onClick={() => setDialog("member")}
-        />
-        {timeline.manageable ? (
-          <PopupMenu
-            items={[
-              {
-                type: "button",
-                text: "timeline.manageItem.property",
-                onClick: () => setDialog("property"),
-              },
-              { type: "divider" },
-              {
-                type: "button",
-                onClick: () => setDialog("delete"),
-                color: "danger",
-                text: "timeline.manageItem.delete",
-              },
-            ]}
-            containerClassName="d-inline"
-          >
-            <IconButton icon="three-dots-vertical" />
-          </PopupMenu>
-        ) : null}
-      </div>
-    </>
-  );
-
-  return (
-    <>
-      <Card className={classnames("p-2 cru-clearfix", className)}>
-        <div
-          className={classnames(
-            "cru-float-right d-flex align-items-center",
-            !collapse && "ms-3"
-          )}
-        >
-          <ConnectionStatusBadge status={connectionStatus} className="me-2" />
-          <CollapseButton collapse={collapse} onClick={toggleCollapse} />
-        </div>
-        {isSmallScreen ? (
-          <FullPageDialog
-            onBack={toggleCollapse}
-            show={!collapse}
-            contentContainerClassName="p-2"
-          >
-            {content}
-          </FullPageDialog>
-        ) : (
-          <div style={{ display: collapse ? "none" : "inline" }}>{content}</div>
-        )}
-      </Card>
-      <TimelineMemberDialog
-        timeline={timeline}
-        onClose={() => setDialog(null)}
-        open={dialog === "member"}
-        onChange={onReload}
-      />
-      <TimelinePropertyChangeDialog
-        timeline={timeline}
-        close={() => setDialog(null)}
-        open={dialog === "property"}
-        onChange={onReload}
-      />
-      <TimelineDeleteDialog
-        timeline={timeline}
-        open={dialog === "delete"}
-        close={() => setDialog(null)}
-      />
-    </>
-  );
-};
-
-export default TimelineCard;
diff --git a/FrontEnd/src/views/timeline/TimelineDateLabel.tsx b/FrontEnd/src/views/timeline/TimelineDateLabel.tsx
deleted file mode 100644
index 5f4ac706..00000000
--- a/FrontEnd/src/views/timeline/TimelineDateLabel.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-import * as React from "react";
-import TimelineLine from "./TimelineLine";
-
-export interface TimelineDateItemProps {
-  date: Date;
-}
-
-const TimelineDateLabel: React.FC<TimelineDateItemProps> = ({ date }) => {
-  return (
-    <div className="timeline-date-item">
-      <TimelineLine center="none" />
-      <div className="timeline-date-item-badge">
-        {date.toLocaleDateString()}
-      </div>
-    </div>
-  );
-};
-
-export default TimelineDateLabel;
diff --git a/FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx b/FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx
deleted file mode 100644
index c960b3c2..00000000
--- a/FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx
+++ /dev/null
@@ -1,61 +0,0 @@
-import * as React from "react";
-import { useNavigate } from "react-router-dom";
-import { Trans } from "react-i18next";
-
-import { getHttpTimelineClient, HttpTimelineInfo } from "@/http/timeline";
-
-import OperationDialog from "../common/dialog/OperationDialog";
-
-interface TimelineDeleteDialog {
-  timeline: HttpTimelineInfo;
-  open: boolean;
-  close: () => void;
-}
-
-const TimelineDeleteDialog: React.FC<TimelineDeleteDialog> = (props) => {
-  const navigate = useNavigate();
-
-  const { timeline } = props;
-
-  return (
-    <OperationDialog
-      open={props.open}
-      onClose={props.close}
-      title="timeline.deleteDialog.title"
-      themeColor="danger"
-      inputPrompt={() => {
-        return (
-          <Trans
-            i18nKey="timeline.deleteDialog.inputPrompt"
-            values={{ name: timeline.nameV2 }}
-          >
-            0<code className="mx-2">1</code>2
-          </Trans>
-        );
-      }}
-      inputScheme={[
-        {
-          type: "text",
-        },
-      ]}
-      inputValidator={([value]) => {
-        if (value !== timeline.nameV2) {
-          return { 0: "timeline.deleteDialog.notMatch" };
-        } else {
-          return null;
-        }
-      }}
-      onProcess={() => {
-        return getHttpTimelineClient().deleteTimeline(
-          timeline.owner.username,
-          timeline.nameV2
-        );
-      }}
-      onSuccessAndClose={() => {
-        navigate("/", { replace: true });
-      }}
-    />
-  );
-};
-
-export default TimelineDeleteDialog;
diff --git a/FrontEnd/src/views/timeline/TimelineEmptyItem.tsx b/FrontEnd/src/views/timeline/TimelineEmptyItem.tsx
deleted file mode 100644
index 5e0728d4..00000000
--- a/FrontEnd/src/views/timeline/TimelineEmptyItem.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import * as React from "react";
-import classnames from "classnames";
-
-import TimelineLine, { TimelineLineProps } from "./TimelineLine";
-
-export interface TimelineEmptyItemProps extends Partial<TimelineLineProps> {
-  height?: number | string;
-  className?: string;
-  style?: React.CSSProperties;
-}
-
-const TimelineEmptyItem: React.FC<TimelineEmptyItemProps> = (props) => {
-  const { height, style, className, center, ...lineProps } = props;
-
-  return (
-    <div
-      style={{ ...style, height: height }}
-      className={classnames("timeline-item", className)}
-    >
-      <TimelineLine center={center ?? "none"} {...lineProps} />
-    </div>
-  );
-};
-
-export default TimelineEmptyItem;
diff --git a/FrontEnd/src/views/timeline/TimelineLine.tsx b/FrontEnd/src/views/timeline/TimelineLine.tsx
deleted file mode 100644
index 4a87e6e0..00000000
--- a/FrontEnd/src/views/timeline/TimelineLine.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-import * as React from "react";
-import classnames from "classnames";
-
-export interface TimelineLineProps {
-  current?: boolean;
-  startSegmentLength?: string | number;
-  center: "node" | "loading" | "none";
-  className?: string;
-  style?: React.CSSProperties;
-}
-
-const TimelineLine: React.FC<TimelineLineProps> = ({
-  startSegmentLength,
-  center,
-  current,
-  className,
-  style,
-}) => {
-  return (
-    <div
-      className={classnames(
-        "timeline-line",
-        current && "current",
-        center === "loading" && "loading",
-        className
-      )}
-      style={style}
-    >
-      <div className="segment start" style={{ height: startSegmentLength }} />
-      {center !== "none" ? (
-        <div className="node-container">
-          <div className="node"></div>
-          {center === "loading" ? (
-            <svg className="node-loading-edge" viewBox="0 0 100 100">
-              <path
-                d="M 50,10 A 40 40 45 0 1 78.28,21.72"
-                stroke="currentcolor"
-                strokeLinecap="square"
-                strokeWidth="8"
-              />
-            </svg>
-          ) : null}
-        </div>
-      ) : null}
-      {center !== "loading" ? <div className="segment end"></div> : null}
-      {current && <div className="segment current-end" />}
-    </div>
-  );
-};
-
-export default TimelineLine;
diff --git a/FrontEnd/src/views/timeline/TimelineLoading.tsx b/FrontEnd/src/views/timeline/TimelineLoading.tsx
deleted file mode 100644
index f876cba9..00000000
--- a/FrontEnd/src/views/timeline/TimelineLoading.tsx
+++ /dev/null
@@ -1,16 +0,0 @@
-import * as React from "react";
-
-import TimelineEmptyItem from "./TimelineEmptyItem";
-
-const TimelineLoading: React.FC = () => {
-  return (
-    <TimelineEmptyItem
-      className="timeline-top-loading-enter"
-      height={100}
-      center="loading"
-      startSegmentLength={56}
-    />
-  );
-};
-
-export default TimelineLoading;
diff --git a/FrontEnd/src/views/timeline/TimelineMember.css b/FrontEnd/src/views/timeline/TimelineMember.css
deleted file mode 100644
index adb78764..00000000
--- a/FrontEnd/src/views/timeline/TimelineMember.css
+++ /dev/null
@@ -1,8 +0,0 @@
-.timeline-member-item {
-  border: var(--cru-background-1-color) solid;
-  border-width: 0.5px 1px;
-}
-
-.timeline-member-item > div {
-  padding: 0.5em;
-}
diff --git a/FrontEnd/src/views/timeline/TimelineMember.tsx b/FrontEnd/src/views/timeline/TimelineMember.tsx
deleted file mode 100644
index aaafd173..00000000
--- a/FrontEnd/src/views/timeline/TimelineMember.tsx
+++ /dev/null
@@ -1,202 +0,0 @@
-import { useState } from "react";
-import * as React from "react";
-import { useTranslation } from "react-i18next";
-
-import { convertI18nText, I18nText } from "@/common";
-
-import { HttpUser } from "@/http/user";
-import { getHttpSearchClient } from "@/http/search";
-import { getHttpTimelineClient, HttpTimelineInfo } from "@/http/timeline";
-
-import SearchInput from "../common/SearchInput";
-import UserAvatar from "../common/user/UserAvatar";
-import Button from "../common/button/Button";
-import Dialog from "../common/dialog/Dialog";
-
-import "./TimelineMember.css";
-
-const TimelineMemberItem: React.FC<{
-  user: HttpUser;
-  add?: boolean;
-  onAction?: (username: string) => void;
-}> = ({ user, add, onAction }) => {
-  return (
-    <div className="container timeline-member-item">
-      <div className="row">
-        <div className="col col-auto">
-          <UserAvatar username={user.username} className="cru-avatar small" />
-        </div>
-        <div className="col">
-          <div className="row">{user.nickname}</div>
-          <small className="row">{"@" + user.username}</small>
-        </div>
-        {onAction ? (
-          <div className="col col-auto">
-            <Button
-              text={`timeline.member.${add ? "add" : "remove"}`}
-              color={add ? "success" : "danger"}
-              onClick={() => {
-                onAction(user.username);
-              }}
-            />
-          </div>
-        ) : null}
-      </div>
-    </div>
-  );
-};
-
-const TimelineMemberUserSearch: React.FC<{
-  timeline: HttpTimelineInfo;
-  onChange: () => void;
-}> = ({ timeline, onChange }) => {
-  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 (
-    <>
-      <SearchInput
-        className="mt-3"
-        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="mt-2">
-                {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>
-          );
-        }
-      })()}
-    </>
-  );
-};
-
-export interface TimelineMemberProps {
-  timeline: HttpTimelineInfo;
-  onChange: () => void;
-}
-
-const TimelineMember: React.FC<TimelineMemberProps> = (props) => {
-  const { timeline, onChange } = props;
-  const members = [timeline.owner, ...timeline.members];
-
-  return (
-    <div className="container px-4 py-3">
-      <div>
-        {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
-            }
-          />
-        ))}
-      </div>
-      {timeline.manageable ? (
-        <TimelineMemberUserSearch timeline={timeline} onChange={onChange} />
-      ) : null}
-    </div>
-  );
-};
-
-export default TimelineMember;
-
-export interface TimelineMemberDialogProps extends TimelineMemberProps {
-  open: boolean;
-  onClose: () => void;
-}
-
-export const TimelineMemberDialog: React.FC<TimelineMemberDialogProps> = (
-  props
-) => {
-  return (
-    <Dialog open={props.open} onClose={props.onClose}>
-      <TimelineMember {...props} />
-    </Dialog>
-  );
-};
diff --git a/FrontEnd/src/views/timeline/TimelinePostContentView.tsx b/FrontEnd/src/views/timeline/TimelinePostContentView.tsx
deleted file mode 100644
index 9ed192e5..00000000
--- a/FrontEnd/src/views/timeline/TimelinePostContentView.tsx
+++ /dev/null
@@ -1,187 +0,0 @@
-import * as React from "react";
-import classnames from "classnames";
-import { marked } from "marked";
-
-import { UiLogicError } from "@/common";
-
-import { HttpNetworkError } from "@/http/common";
-import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline";
-
-import { useUser } from "@/services/user";
-
-import Skeleton from "../common/Skeleton";
-import LoadFailReload from "../common/LoadFailReload";
-
-const TextView: React.FC<TimelinePostContentViewProps> = (props) => {
-  const { post, className, style } = props;
-
-  const [text, setText] = React.useState<string | null>(null);
-  const [error, setError] = React.useState<"offline" | "error" | null>(null);
-
-  const [reloadKey, setReloadKey] = React.useState<number>(0);
-
-  React.useEffect(() => {
-    let subscribe = true;
-
-    setText(null);
-    setError(null);
-
-    void getHttpTimelineClient()
-      .getPostDataAsString(post.timelineOwnerV2, post.timelineNameV2, post.id)
-      .then(
-        (data) => {
-          if (subscribe) setText(data);
-        },
-        (error) => {
-          if (subscribe) {
-            if (error instanceof HttpNetworkError) {
-              setError("offline");
-            } else {
-              setError("error");
-            }
-          }
-        }
-      );
-
-    return () => {
-      subscribe = false;
-    };
-  }, [post.timelineOwnerV2, post.timelineNameV2, post.id, reloadKey]);
-
-  if (error != null) {
-    return (
-      <LoadFailReload
-        className={className}
-        style={style}
-        onReload={() => setReloadKey(reloadKey + 1)}
-      />
-    );
-  } else if (text == null) {
-    return <Skeleton />;
-  } else {
-    return (
-      <div className={className} style={style}>
-        {text}
-      </div>
-    );
-  }
-};
-
-const ImageView: React.FC<TimelinePostContentViewProps> = (props) => {
-  const { post, className, style } = props;
-
-  useUser();
-
-  return (
-    <img
-      src={getHttpTimelineClient().generatePostDataUrl(
-        post.timelineOwnerV2,
-        post.timelineNameV2,
-        post.id
-      )}
-      className={classnames(className, "timeline-content-image")}
-      style={style}
-    />
-  );
-};
-
-const MarkdownView: React.FC<TimelinePostContentViewProps> = (props) => {
-  const { post, className, style } = props;
-
-  const [markdown, setMarkdown] = React.useState<string | null>(null);
-  const [error, setError] = React.useState<"offline" | "error" | null>(null);
-
-  const [reloadKey, setReloadKey] = React.useState<number>(0);
-
-  React.useEffect(() => {
-    let subscribe = true;
-
-    setMarkdown(null);
-    setError(null);
-
-    void getHttpTimelineClient()
-      .getPostDataAsString(post.timelineOwnerV2, post.timelineNameV2, post.id)
-      .then(
-        (data) => {
-          if (subscribe) setMarkdown(data);
-        },
-        (error) => {
-          if (subscribe) {
-            if (error instanceof HttpNetworkError) {
-              setError("offline");
-            } else {
-              setError("error");
-            }
-          }
-        }
-      );
-
-    return () => {
-      subscribe = false;
-    };
-  }, [post.timelineOwnerV2, post.timelineNameV2, post.id, reloadKey]);
-
-  const markdownHtml = React.useMemo<string | null>(() => {
-    if (markdown == null) return null;
-    return marked.parse(markdown);
-  }, [markdown]);
-
-  if (error != null) {
-    return (
-      <LoadFailReload
-        className={className}
-        style={style}
-        onReload={() => setReloadKey(reloadKey + 1)}
-      />
-    );
-  } else if (markdown == null) {
-    return <Skeleton />;
-  } else {
-    if (markdownHtml == null) {
-      throw new UiLogicError("Markdown is not null but markdown html is.");
-    }
-    return (
-      <div
-        className={classnames(className, "markdown-container")}
-        style={style}
-        dangerouslySetInnerHTML={{
-          __html: markdownHtml,
-        }}
-      />
-    );
-  }
-};
-
-export interface TimelinePostContentViewProps {
-  post: HttpTimelinePostInfo;
-  className?: string;
-  style?: React.CSSProperties;
-}
-
-const viewMap: Record<string, React.FC<TimelinePostContentViewProps>> = {
-  "text/plain": TextView,
-  "text/markdown": MarkdownView,
-  "image/png": ImageView,
-  "image/jpeg": ImageView,
-  "image/gif": ImageView,
-  "image/webp": ImageView,
-};
-
-const TimelinePostContentView: React.FC<TimelinePostContentViewProps> = (
-  props
-) => {
-  const { post, className, style } = props;
-
-  const type = post.dataList[0].kind;
-
-  if (type in viewMap) {
-    const View = viewMap[type];
-    return <View post={post} className={className} style={style} />;
-  } else {
-    // TODO: i18n
-    console.error("Unknown post type", post);
-    return <div>Error, unknown post type!</div>;
-  }
-};
-
-export default TimelinePostContentView;
diff --git a/FrontEnd/src/views/timeline/TimelinePostEdit.css b/FrontEnd/src/views/timeline/TimelinePostEdit.css
deleted file mode 100644
index 9b7629e2..00000000
--- a/FrontEnd/src/views/timeline/TimelinePostEdit.css
+++ /dev/null
@@ -1,10 +0,0 @@
-.timeline-post-edit {
-  position: sticky !important;
-  top: 106px;
-  z-index: 100;
-}
-
-.timeline-post-edit-image {
-  max-width: 100px;
-  max-height: 100px;
-}
diff --git a/FrontEnd/src/views/timeline/TimelinePostEdit.tsx b/FrontEnd/src/views/timeline/TimelinePostEdit.tsx
deleted file mode 100644
index 38e72264..00000000
--- a/FrontEnd/src/views/timeline/TimelinePostEdit.tsx
+++ /dev/null
@@ -1,267 +0,0 @@
-import * as React from "react";
-import { useTranslation } from "react-i18next";
-
-import { UiLogicError } from "@/common";
-
-import {
-  getHttpTimelineClient,
-  HttpTimelineInfo,
-  HttpTimelinePostInfo,
-  HttpTimelinePostPostRequestData,
-} from "@/http/timeline";
-
-import { pushAlert } from "@/services/alert";
-
-import base64 from "@/utilities/base64";
-
-import BlobImage from "../common/BlobImage";
-import LoadingButton from "../common/button/LoadingButton";
-import PopupMenu from "../common/menu/PopupMenu";
-import MarkdownPostEdit from "./MarkdownPostEdit";
-import TimelinePostEditCard from "./TimelinePostEditCard";
-import IconButton from "../common/button/IconButton";
-
-import "./TimelinePostEdit.css";
-
-interface TimelinePostEditTextProps {
-  text: string;
-  disabled: boolean;
-  onChange: (text: string) => void;
-  className?: string;
-  style?: React.CSSProperties;
-}
-
-const TimelinePostEditText: React.FC<TimelinePostEditTextProps> = (props) => {
-  const { text, disabled, onChange, className, style } = props;
-
-  return (
-    <textarea
-      value={text}
-      disabled={disabled}
-      onChange={(event) => {
-        onChange(event.target.value);
-      }}
-      className={className}
-      style={style}
-    />
-  );
-};
-
-interface TimelinePostEditImageProps {
-  onSelect: (file: File | null) => void;
-  disabled: boolean;
-}
-
-const TimelinePostEditImage: React.FC<TimelinePostEditImageProps> = (props) => {
-  const { onSelect, disabled } = props;
-
-  const { t } = useTranslation();
-
-  const [file, setFile] = React.useState<File | null>(null);
-  const [error, setError] = React.useState<boolean>(false);
-
-  const onInputChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
-    setError(false);
-    const files = e.target.files;
-    if (files == null || files.length === 0) {
-      setFile(null);
-      onSelect(null);
-    } else {
-      setFile(files[0]);
-    }
-  };
-
-  React.useEffect(() => {
-    return () => {
-      onSelect(null);
-    };
-  }, [onSelect]);
-
-  return (
-    <>
-      <input
-        type="file"
-        onChange={onInputChange}
-        accept="image/*"
-        disabled={disabled}
-        className="mx-3 my-1"
-      />
-      {file != null && !error && (
-        <BlobImage
-          blob={file}
-          className="timeline-post-edit-image"
-          onLoad={() => onSelect(file)}
-          onError={() => {
-            onSelect(null);
-            setError(true);
-          }}
-        />
-      )}
-      {error ? <div className="text-danger">{t("loadImageError")}</div> : null}
-    </>
-  );
-};
-
-type PostKind = "text" | "markdown" | "image";
-
-const postKindIconMap: Record<PostKind, string> = {
-  text: "fonts",
-  markdown: "markdown",
-  image: "image",
-};
-
-export interface TimelinePostEditProps {
-  className?: string;
-  style?: React.CSSProperties;
-  timeline: HttpTimelineInfo;
-  onPosted: (newPost: HttpTimelinePostInfo) => void;
-}
-
-const TimelinePostEdit: React.FC<TimelinePostEditProps> = (props) => {
-  const { timeline, style, className, onPosted } = props;
-
-  const { t } = useTranslation();
-
-  const [process, setProcess] = React.useState<boolean>(false);
-
-  const [kind, setKind] = React.useState<Exclude<PostKind, "markdown">>("text");
-  const [showMarkdown, setShowMarkdown] = React.useState<boolean>(false);
-
-  const [text, setText] = React.useState<string>("");
-  const [image, setImage] = React.useState<File | null>(null);
-
-  const draftTextLocalStorageKey = `timeline.${timeline.owner.username}.${timeline.nameV2}.postDraft.text`;
-
-  React.useEffect(() => {
-    setText(window.localStorage.getItem(draftTextLocalStorageKey) ?? "");
-  }, [draftTextLocalStorageKey]);
-
-  const canSend =
-    (kind === "text" && text.length !== 0) ||
-    (kind === "image" && image != null);
-
-  const onPostError = (): void => {
-    pushAlert({
-      type: "danger",
-      message: "timeline.sendPostFailed",
-    });
-  };
-
-  const onSend = async (): Promise<void> => {
-    setProcess(true);
-
-    let requestData: HttpTimelinePostPostRequestData;
-    switch (kind) {
-      case "text":
-        requestData = {
-          contentType: "text/plain",
-          data: await base64(text),
-        };
-        break;
-      case "image":
-        if (image == null) {
-          throw new UiLogicError(
-            "Content type is image but image blob is null.",
-          );
-        }
-        requestData = {
-          contentType: image.type,
-          data: await base64(image),
-        };
-        break;
-      default:
-        throw new UiLogicError("Unknown content type.");
-    }
-
-    getHttpTimelineClient()
-      .postPost(timeline.owner.username, timeline.nameV2, {
-        dataList: [requestData],
-      })
-      .then(
-        (data) => {
-          if (kind === "text") {
-            setText("");
-            window.localStorage.removeItem(draftTextLocalStorageKey);
-          }
-          setProcess(false);
-          setKind("text");
-          onPosted(data);
-        },
-        () => {
-          setProcess(false);
-          onPostError();
-        },
-      );
-  };
-
-  return (
-    <TimelinePostEditCard className={className} style={style}>
-      {showMarkdown ? (
-        <MarkdownPostEdit
-          className="cru-fill-parent"
-          onClose={() => setShowMarkdown(false)}
-          owner={timeline.owner.username}
-          timeline={timeline.nameV2}
-          onPosted={onPosted}
-          onPostError={onPostError}
-        />
-      ) : (
-        <div className="row">
-          <div className="col px-1 py-1">
-            {(() => {
-              if (kind === "text") {
-                return (
-                  <TimelinePostEditText
-                    className="cru-fill-parent timeline-post-edit"
-                    text={text}
-                    disabled={process}
-                    onChange={(t) => {
-                      setText(t);
-                      window.localStorage.setItem(draftTextLocalStorageKey, t);
-                    }}
-                  />
-                );
-              } else if (kind === "image") {
-                return (
-                  <TimelinePostEditImage
-                    onSelect={setImage}
-                    disabled={process}
-                  />
-                );
-              }
-            })()}
-          </div>
-          <div className="col col-auto align-self-end m-1">
-            <div className="d-block cru-text-center mt-1 mb-2">
-              <PopupMenu
-                items={(["text", "image", "markdown"] as const).map((kind) => ({
-                  type: "button",
-                  text: `timeline.post.type.${kind}`,
-                  iconClassName: postKindIconMap[kind],
-                  onClick: () => {
-                    if (kind === "markdown") {
-                      setShowMarkdown(true);
-                    } else {
-                      setKind(kind);
-                    }
-                  },
-                }))}
-              >
-                <IconButton large icon={postKindIconMap[kind]} />
-              </PopupMenu>
-            </div>
-            <LoadingButton
-              onClick={() => void onSend()}
-              disabled={!canSend}
-              loading={process}
-            >
-              {t("timeline.send")}
-            </LoadingButton>
-          </div>
-        </div>
-      )}
-    </TimelinePostEditCard>
-  );
-};
-
-export default TimelinePostEdit;
diff --git a/FrontEnd/src/views/timeline/TimelinePostEditCard.tsx b/FrontEnd/src/views/timeline/TimelinePostEditCard.tsx
deleted file mode 100644
index d2f7bd72..00000000
--- a/FrontEnd/src/views/timeline/TimelinePostEditCard.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import * as React from "react";
-import classnames from "classnames";
-
-import Card from "../common/Card";
-import TimelineLine from "./TimelineLine";
-
-import "./TimelinePostEdit.css";
-
-export interface TimelinePostEditCardProps {
-  className?: string;
-  style?: React.CSSProperties;
-  children?: React.ReactNode;
-}
-
-const TimelinePostEdit: React.FC<TimelinePostEditCardProps> = ({
-  className,
-  style,
-  children,
-}) => {
-  return (
-    <div
-      className={classnames("timeline-item timeline-post-edit", className)}
-      style={style}
-    >
-      <TimelineLine center="node" />
-      <Card className="timeline-item-card">{children}</Card>
-    </div>
-  );
-};
-
-export default TimelinePostEdit;
diff --git a/FrontEnd/src/views/timeline/TimelinePostEditNoLogin.tsx b/FrontEnd/src/views/timeline/TimelinePostEditNoLogin.tsx
deleted file mode 100644
index 1ef0a287..00000000
--- a/FrontEnd/src/views/timeline/TimelinePostEditNoLogin.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import * as React from "react";
-import { Trans } from "react-i18next";
-import { Link } from "react-router-dom";
-
-import TimelinePostEditCard from "./TimelinePostEditCard";
-
-export default function TimelinePostEditNoLogin(): React.ReactElement | null {
-  return (
-    <TimelinePostEditCard>
-      <div className="mt-3 mb-4">
-        <Trans
-          i18nKey="timeline.postNoLogin"
-          components={{ l: <Link to="/login" /> }}
-        />
-      </div>
-    </TimelinePostEditCard>
-  );
-}
diff --git a/FrontEnd/src/views/timeline/TimelinePostListView.tsx b/FrontEnd/src/views/timeline/TimelinePostListView.tsx
deleted file mode 100644
index f878b004..00000000
--- a/FrontEnd/src/views/timeline/TimelinePostListView.tsx
+++ /dev/null
@@ -1,76 +0,0 @@
-import { Fragment } from "react";
-import * as React from "react";
-
-import { HttpTimelinePostInfo } from "@/http/timeline";
-
-import TimelinePostView from "./TimelinePostView";
-import TimelineDateLabel from "./TimelineDateLabel";
-
-function dateEqual(left: Date, right: Date): boolean {
-  return (
-    left.getDate() == right.getDate() &&
-    left.getMonth() == right.getMonth() &&
-    left.getFullYear() == right.getFullYear()
-  );
-}
-
-export interface TimelinePostListViewProps {
-  posts: HttpTimelinePostInfo[];
-  onReload: () => void;
-}
-
-const TimelinePostListView: React.FC<TimelinePostListViewProps> = (props) => {
-  const { posts, onReload } = props;
-
-  const groupedPosts = React.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 (
-    <>
-      {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>
-        );
-      })}
-    </>
-  );
-};
-
-export default TimelinePostListView;
diff --git a/FrontEnd/src/views/timeline/TimelinePostView.tsx b/FrontEnd/src/views/timeline/TimelinePostView.tsx
deleted file mode 100644
index e3eac0f4..00000000
--- a/FrontEnd/src/views/timeline/TimelinePostView.tsx
+++ /dev/null
@@ -1,159 +0,0 @@
-import * as React from "react";
-import classnames from "classnames";
-
-import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline";
-
-import { pushAlert } from "@/services/alert";
-
-import { useClickOutside } from "@/utilities/hooks";
-
-import UserAvatar from "../common/user/UserAvatar";
-import Card from "../common/Card";
-import FlatButton from "../common/button/FlatButton";
-import ConfirmDialog from "../common/dialog/ConfirmDialog";
-import TimelineLine from "./TimelineLine";
-import TimelinePostContentView from "./TimelinePostContentView";
-import PostPropertyChangeDialog from "./PostPropertyChangeDialog";
-import IconButton from "../common/button/IconButton";
-
-export interface TimelinePostViewProps {
-  post: HttpTimelinePostInfo;
-  className?: string;
-  style?: React.CSSProperties;
-  cardStyle?: React.CSSProperties;
-  onChanged: (post: HttpTimelinePostInfo) => void;
-  onDeleted: () => void;
-}
-
-const TimelinePostView: React.FC<TimelinePostViewProps> = (props) => {
-  const { post, className, style, cardStyle, onChanged, onDeleted } = props;
-
-  const [operationMaskVisible, setOperationMaskVisible] =
-    React.useState<boolean>(false);
-  const [dialog, setDialog] = React.useState<
-    "delete" | "changeproperty" | null
-  >(null);
-
-  const [maskElement, setMaskElement] = React.useState<HTMLElement | null>(
-    null
-  );
-
-  useClickOutside(maskElement, () => setOperationMaskVisible(false));
-
-  const cardRef = React.useRef<HTMLDivElement>(null);
-  React.useEffect(() => {
-    const cardIntersectionObserver = new IntersectionObserver(([e]) => {
-      if (e.intersectionRatio > 0) {
-        if (cardRef.current != null) {
-          cardRef.current.style.animationName = "timeline-post-enter";
-        }
-      }
-    });
-    if (cardRef.current) {
-      cardIntersectionObserver.observe(cardRef.current);
-    }
-
-    return () => {
-      cardIntersectionObserver.disconnect();
-    };
-  }, []);
-
-  return (
-    <div
-      id={`timeline-post-${post.id}`}
-      className={classnames("timeline-item", className)}
-      style={style}
-    >
-      <TimelineLine center="node" />
-      <Card
-        ref={cardRef}
-        className="timeline-item-card enter-animation"
-        style={cardStyle}
-      >
-        {post.editable ? (
-          <IconButton
-            icon="chevron-down"
-            color="primary-enhance"
-            className="cru-float-right"
-            onClick={(e) => {
-              setOperationMaskVisible(true);
-              e.stopPropagation();
-            }}
-          />
-        ) : null}
-        <div className="timeline-item-header">
-          <span className="me-2">
-            <span>
-              <UserAvatar
-                username={post.author.username}
-                className="timeline-avatar me-1"
-              />
-              <small className="text-dark me-2">{post.author.nickname}</small>
-              <small className="text-secondary white-space-no-wrap">
-                {new Date(post.time).toLocaleTimeString()}
-              </small>
-            </span>
-          </span>
-        </div>
-        <div className="timeline-content">
-          <TimelinePostContentView post={post} />
-        </div>
-        {operationMaskVisible ? (
-          <div
-            ref={setMaskElement}
-            className="timeline-post-item-options-mask"
-            onClick={() => {
-              setOperationMaskVisible(false);
-            }}
-          >
-            <FlatButton
-              text="changeProperty"
-              onClick={(e) => {
-                setDialog("changeproperty");
-                e.stopPropagation();
-              }}
-            />
-            <FlatButton
-              text="delete"
-              color="danger"
-              onClick={(e) => {
-                setDialog("delete");
-                e.stopPropagation();
-              }}
-            />
-          </div>
-        ) : null}
-      </Card>
-      <ConfirmDialog
-        title="timeline.post.deleteDialog.title"
-        body="timeline.post.deleteDialog.prompt"
-        open={dialog === "delete"}
-        onClose={() => {
-          setDialog(null);
-          setOperationMaskVisible(false);
-        }}
-        onConfirm={() => {
-          void getHttpTimelineClient()
-            .deletePost(post.timelineOwnerV2, post.timelineNameV2, post.id)
-            .then(onDeleted, () => {
-              pushAlert({
-                type: "danger",
-                message: "timeline.deletePostFailed",
-              });
-            });
-        }}
-      />
-      <PostPropertyChangeDialog
-        open={dialog === "changeproperty"}
-        onClose={() => {
-          setDialog(null);
-          setOperationMaskVisible(false);
-        }}
-        post={post}
-        onSuccess={onChanged}
-      />
-    </div>
-  );
-};
-
-export default TimelinePostView;
diff --git a/FrontEnd/src/views/timeline/TimelinePropertyChangeDialog.tsx b/FrontEnd/src/views/timeline/TimelinePropertyChangeDialog.tsx
deleted file mode 100644
index bd5bef4c..00000000
--- a/FrontEnd/src/views/timeline/TimelinePropertyChangeDialog.tsx
+++ /dev/null
@@ -1,82 +0,0 @@
-import * as React from "react";
-
-import {
-  getHttpTimelineClient,
-  HttpTimelineInfo,
-  HttpTimelinePatchRequest,
-  kTimelineVisibilities,
-  TimelineVisibility,
-} from "@/http/timeline";
-
-import OperationDialog from "../common/dialog/OperationDialog";
-
-export interface TimelinePropertyChangeDialogProps {
-  open: boolean;
-  close: () => void;
-  timeline: HttpTimelineInfo;
-  onChange: () => void;
-}
-
-const labelMap: { [key in TimelineVisibility]: string } = {
-  Private: "timeline.visibility.private",
-  Public: "timeline.visibility.public",
-  Register: "timeline.visibility.register",
-};
-
-const TimelinePropertyChangeDialog: React.FC<
-  TimelinePropertyChangeDialogProps
-> = (props) => {
-  const { timeline, onChange } = props;
-
-  return (
-    <OperationDialog
-      title={"timeline.dialogChangeProperty.title"}
-      inputScheme={
-        [
-          {
-            type: "text",
-            label: "timeline.dialogChangeProperty.titleField",
-            initValue: timeline.title,
-          },
-          {
-            type: "select",
-            label: "timeline.dialogChangeProperty.visibility",
-            options: kTimelineVisibilities.map((v) => ({
-              label: labelMap[v],
-              value: v,
-            })),
-            initValue: timeline.visibility,
-          },
-          {
-            type: "text",
-            label: "timeline.dialogChangeProperty.description",
-            initValue: timeline.description,
-          },
-        ] as const
-      }
-      open={props.open}
-      onClose={props.close}
-      onProcess={([newTitle, newVisibility, newDescription, newColor]) => {
-        const req: HttpTimelinePatchRequest = {};
-        if (newTitle !== timeline.title) {
-          req.title = newTitle;
-        }
-        if (newVisibility !== timeline.visibility) {
-          req.visibility = newVisibility as TimelineVisibility;
-        }
-        if (newDescription !== timeline.description) {
-          req.description = newDescription;
-        }
-        const nc = newColor ?? "";
-        if (nc !== timeline.color) {
-          req.color = nc;
-        }
-        return getHttpTimelineClient()
-          .patchTimeline(timeline.owner.username, timeline.nameV2, req)
-          .then(onChange);
-      }}
-    />
-  );
-};
-
-export default TimelinePropertyChangeDialog;
diff --git a/FrontEnd/src/views/timeline/index.tsx b/FrontEnd/src/views/timeline/index.tsx
deleted file mode 100644
index 1dffdcc1..00000000
--- a/FrontEnd/src/views/timeline/index.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-import * as React from "react";
-import { useParams } from "react-router-dom";
-
-import { UiLogicError } from "@/common";
-
-import Timeline from "./Timeline";
-
-const TimelinePage: React.FC = () => {
-  const { owner, timeline: timelineNameParam } = useParams();
-
-  if (owner == null || owner == "")
-    throw new UiLogicError("Route param owner is not set.");
-
-  const timeline = timelineNameParam || "self";
-
-  return (
-    <div className="container">
-      <Timeline timelineOwner={owner} timelineName={timeline} />
-    </div>
-  );
-};
-
-export default TimelinePage;
-- 
cgit v1.2.3

