From 0e183074b326cf04a23ae1f1ba8dcc56166df485 Mon Sep 17 00:00:00 2001
From: crupest <crupest@outlook.com>
Date: Thu, 20 Jul 2023 20:44:15 +0800
Subject: ...

---
 FrontEnd/src/App.tsx | 20 ++++----------------
 1 file changed, 4 insertions(+), 16 deletions(-)

(limited to 'FrontEnd/src/App.tsx')

diff --git a/FrontEnd/src/App.tsx b/FrontEnd/src/App.tsx
index cfdab229..07a8780f 100644
--- a/FrontEnd/src/App.tsx
+++ b/FrontEnd/src/App.tsx
@@ -2,35 +2,25 @@ import * as React from "react";
 import { BrowserRouter, Route, Routes } from "react-router-dom";
 
 import AppBar from "./views/common/AppBar";
+import NotFoundPage from "./pages/404";
 import LoadingPage from "./views/common/LoadingPage";
+import About from "./pages/about";
 import Center from "./views/center";
-import Home from "./views/home";
 import Login from "./views/login";
 import Register from "./views/register";
 import Settings from "./views/settings";
-import About from "./views/about";
 import TimelinePage from "./views/timeline";
 import Search from "./views/search";
 import Admin from "./views/admin";
 import AlertHost from "./views/common/alert/AlertHost";
 
-import { useUser } from "./services/user";
-
-const NoMatch: React.FC = () => {
-  return <div>Ah-oh, 404!</div>;
-};
-
-function App(): JSX.Element {
-  const user = useUser();
-
+export default function App() {
   return (
     <React.Suspense fallback={<LoadingPage />}>
       <BrowserRouter>
         <AppBar />
         <div style={{ height: 56 }} />
         <Routes>
-          <Route index element={user == null ? <Home /> : <Center />} />
-          <Route path="home" element={<Home />} />
           <Route path="center" element={<Center />} />
           <Route path="login" element={<Login />} />
           <Route path="register" element={<Register />} />
@@ -40,12 +30,10 @@ function App(): JSX.Element {
           <Route path="admin/*" element={<Admin />} />
           <Route path=":owner" element={<TimelinePage />} />
           <Route path=":owner/:timeline" element={<TimelinePage />} />
-          <Route element={<NoMatch />} />
+          <Route path="*" element={<NotFoundPage />} />
         </Routes>
         <AlertHost />
       </BrowserRouter>
     </React.Suspense>
   );
 }
-
-export default App;
-- 
cgit v1.2.3


From d7b050ef7f047f841ffcda96fbdc9857574e97b9 Mon Sep 17 00:00:00 2001
From: crupest <crupest@outlook.com>
Date: Fri, 21 Jul 2023 17:02:05 +0800
Subject: ...

---
 FrontEnd/src/App.tsx                               |   8 +-
 FrontEnd/src/index.css                             |   1 -
 FrontEnd/src/pages/about/index.css                 |   1 -
 FrontEnd/src/pages/about/index.tsx                 |   5 +-
 FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx  |   6 +-
 .../src/pages/setting/ChangeNicknameDialog.tsx     |   2 +-
 .../src/pages/setting/ChangePasswordDialog.tsx     |   2 +-
 FrontEnd/src/pages/setting/index.css               |  54 +++-
 FrontEnd/src/pages/setting/index.tsx               | 171 +++++-----
 FrontEnd/src/views/common/Card.css                 |   8 +-
 FrontEnd/src/views/common/Card.tsx                 |   8 +-
 FrontEnd/src/views/common/Page.tsx                 |  15 +
 FrontEnd/src/views/common/index.css                |   8 +
 FrontEnd/src/views/common/theme.css                |   5 +-
 FrontEnd/src/views/settings/ChangeAvatarDialog.tsx | 354 ---------------------
 .../src/views/settings/ChangeNicknameDialog.tsx    |  34 --
 .../src/views/settings/ChangePasswordDialog.tsx    |  69 ----
 FrontEnd/src/views/settings/index.css              |  31 --
 FrontEnd/src/views/settings/index.tsx              | 338 --------------------
 19 files changed, 182 insertions(+), 938 deletions(-)
 create mode 100644 FrontEnd/src/views/common/Page.tsx
 delete mode 100644 FrontEnd/src/views/settings/ChangeAvatarDialog.tsx
 delete mode 100644 FrontEnd/src/views/settings/ChangeNicknameDialog.tsx
 delete mode 100644 FrontEnd/src/views/settings/ChangePasswordDialog.tsx
 delete mode 100644 FrontEnd/src/views/settings/index.css
 delete mode 100644 FrontEnd/src/views/settings/index.tsx

(limited to 'FrontEnd/src/App.tsx')

diff --git a/FrontEnd/src/App.tsx b/FrontEnd/src/App.tsx
index 07a8780f..92fe0652 100644
--- a/FrontEnd/src/App.tsx
+++ b/FrontEnd/src/App.tsx
@@ -4,11 +4,11 @@ import { BrowserRouter, Route, Routes } from "react-router-dom";
 import AppBar from "./views/common/AppBar";
 import NotFoundPage from "./pages/404";
 import LoadingPage from "./views/common/LoadingPage";
-import About from "./pages/about";
+import AboutPage from "./pages/about";
+import SettingPage from "./pages/setting";
 import Center from "./views/center";
 import Login from "./views/login";
 import Register from "./views/register";
-import Settings from "./views/settings";
 import TimelinePage from "./views/timeline";
 import Search from "./views/search";
 import Admin from "./views/admin";
@@ -24,8 +24,8 @@ export default function App() {
           <Route path="center" element={<Center />} />
           <Route path="login" element={<Login />} />
           <Route path="register" element={<Register />} />
-          <Route path="settings" element={<Settings />} />
-          <Route path="about" element={<About />} />
+          <Route path="settings" element={<SettingPage />} />
+          <Route path="about" element={<AboutPage />} />
           <Route path="search" element={<Search />} />
           <Route path="admin/*" element={<Admin />} />
           <Route path=":owner" element={<TimelinePage />} />
diff --git a/FrontEnd/src/index.css b/FrontEnd/src/index.css
index 3478db05..49791c23 100644
--- a/FrontEnd/src/index.css
+++ b/FrontEnd/src/index.css
@@ -1,4 +1,3 @@
-@import "npm:bootstrap/dist/css/bootstrap-grid.css";
 @import "npm:bootstrap-icons/font/bootstrap-icons.css";
 
 @import "./views/common/index.css";
diff --git a/FrontEnd/src/pages/about/index.css b/FrontEnd/src/pages/about/index.css
index 487f4a0a..1ce7a7c8 100644
--- a/FrontEnd/src/pages/about/index.css
+++ b/FrontEnd/src/pages/about/index.css
@@ -1,5 +1,4 @@
 .about-page {
-  padding: 1em 2em;
   line-height: 1.5;
 }
 
diff --git a/FrontEnd/src/pages/about/index.tsx b/FrontEnd/src/pages/about/index.tsx
index afd4de34..acec1735 100644
--- a/FrontEnd/src/pages/about/index.tsx
+++ b/FrontEnd/src/pages/about/index.tsx
@@ -1,6 +1,7 @@
 import "./index.css";
 
 import { useC } from "@/common";
+import Page from "@/views/common/Page";
 
 interface Credit {
   name: string;
@@ -52,7 +53,7 @@ export default function AboutPage() {
   const c = useC();
 
   return (
-    <div className="about-page">
+    <Page className="about-page">
       <h2>{c("about.credits.title")}</h2>
       <p>{c("about.credits.content")}</p>
       <h3>{c("about.credits.frontend")}</h3>
@@ -81,6 +82,6 @@ export default function AboutPage() {
         })}
         <li>...</li>
       </ul>
-    </div>
+    </Page>
   );
 }
diff --git a/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx b/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx
index 44bd2c68..b2a4e2a8 100644
--- a/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx
+++ b/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx
@@ -9,9 +9,9 @@ import { useUser } from "@/services/user";
 
 import { getHttpUserClient } from "@/http/user";
 
-import ImageCropper, { Clip, applyClipToImage } from "../common/ImageCropper";
-import Button from "../common/button/Button";
-import Dialog from "../common/dialog/Dialog";
+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;
diff --git a/FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx b/FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx
index 7ba12de8..11c86222 100644
--- a/FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx
+++ b/FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx
@@ -2,7 +2,7 @@ import { getHttpUserClient } from "@/http/user";
 import { useUser } from "@/services/user";
 import * as React from "react";
 
-import OperationDialog from "../common/dialog/OperationDialog";
+import OperationDialog from "@/views/common/dialog/OperationDialog";
 
 export interface ChangeNicknameDialogProps {
   open: boolean;
diff --git a/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx b/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx
index a34ca4a7..a523b454 100644
--- a/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx
+++ b/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx
@@ -4,7 +4,7 @@ import { useNavigate } from "react-router-dom";
 
 import { userService } from "@/services/user";
 
-import OperationDialog from "../common/dialog/OperationDialog";
+import OperationDialog from "@/views/common/dialog/OperationDialog";
 
 export interface ChangePasswordDialogProps {
   open: boolean;
diff --git a/FrontEnd/src/pages/setting/index.css b/FrontEnd/src/pages/setting/index.css
index ccf7a97a..af5ccf20 100644
--- a/FrontEnd/src/pages/setting/index.css
+++ b/FrontEnd/src/pages/setting/index.css
@@ -1,3 +1,5 @@
+/* TODO: Make item prettier. */
+
 .change-avatar-cropper-row {
   max-height: 400px;
 }
@@ -8,20 +10,56 @@
   max-height: 400px;
 }
 
-.settings-item {
+.setting-section {
+  padding: 1em 0;
+  margin: 1em 0;
+}
+
+.setting-section-title {
+  padding: 0 1em;
+}
+
+.setting-section-item-area {
+  margin-top: 1em;
+  border-top: 1px solid var(--cru-key-container-color);
+}
+
+.setting-item-container {
   padding: 0.5em 1em;
-  transition: background 0.3s;
-  border-bottom: 1px solid #e9ecef;
+  transition: background-color 0.3s;
+  color: var(--cru-surface-on-color);
+  background-color: var(--cru-surface-color);
+  border-bottom: 1px solid var(--cru-key-container-color);
+  display: flex;
   align-items: center;
 }
-.settings-item.first {
-  border-top: 1px solid #e9ecef;
+
+.setting-item-container:hover {
+  background-color: var(--cru-key-container-1-color);
+  border-bottom-color: var(--cru-key-container-1-color);
+}
+
+.setting-item-label-sub {
+  color: var(--cru-secondary-text-color);
 }
-.settings-item.clickable {
+
+.setting-item-container.setting-type-button {
   cursor: pointer;
 }
-.settings-item:hover {
-  background: #dee2e6;
+
+.setting-item-container.setting-type-button.danger {
+  color: var(--cru-danger-color);
+}
+
+
+@media (max-width: 576) {
+  .setting-item-container.setting-type-select {
+    flex-direction: column;
+  }
+
+  .setting-item-container.setting-type-select .setting-item-value-area {
+    margin-top: 1em;
+  }
 }
 
 .register-code {
diff --git a/FrontEnd/src/pages/setting/index.tsx b/FrontEnd/src/pages/setting/index.tsx
index 4e28585e..5d6dcbc0 100644
--- a/FrontEnd/src/pages/setting/index.tsx
+++ b/FrontEnd/src/pages/setting/index.tsx
@@ -11,11 +11,11 @@ import classNames from "classnames";
 import { useC, Text } from "@/common";
 import { useUser, userService } from "@/services/user";
 import { getHttpUserClient } from "@/http/user";
-import { TimelineVisibility } from "@/http/timeline";
 
 import ConfirmDialog from "@/views/common/dialog/ConfirmDialog";
 import Card from "@/views/common/Card";
 import Spinner from "@/views/common/Spinner";
+import Page from "@/views/common/Page";
 import ChangePasswordDialog from "./ChangePasswordDialog";
 import ChangeAvatarDialog from "./ChangeAvatarDialog";
 import ChangeNicknameDialog from "./ChangeNicknameDialog";
@@ -24,9 +24,9 @@ import "./index.css";
 import { pushAlert } from "@/services/alert";
 
 interface SettingSectionProps
-  extends Omit<ComponentPropsWithoutRef<"div">, "title"> {
+  extends Omit<ComponentPropsWithoutRef<typeof Card>, "title"> {
   title: Text;
-  children: ReactNode;
+  children?: ReactNode;
 }
 
 function SettingSection({
@@ -40,7 +40,7 @@ function SettingSection({
   return (
     <Card className={classNames(className, "setting-section")} {...otherProps}>
       <h2 className="setting-section-title">{c(title)}</h2>
-      {children}
+      <div className="setting-section-item-area">{children}</div>
     </Card>
   );
 }
@@ -115,6 +115,7 @@ function SelectSettingsItem({
         <Spinner />
       ) : (
         <select
+          className="select-setting-item-select"
           value={value}
           onChange={(e) => {
             onSelect(e.target.value);
@@ -131,16 +132,14 @@ function SelectSettingsItem({
   );
 }
 
-function RegisterCodeSettingItem({
-  openRenewDialog,
-}: {
-  openRenewDialog: () => void;
-}) {
+function RegisterCodeSettingItem() {
   const user = useUser();
 
   // undefined: loading
   const [registerCode, setRegisterCode] = useState<undefined | null | string>();
 
+  const [dialogOpen, setDialogOpen] = useState(false);
+
   useEffect(() => {
     setRegisterCode(undefined);
   }, [user]);
@@ -159,7 +158,7 @@ function RegisterCodeSettingItem({
     <SettingItemContainer
       title="settings.myRegisterCode"
       description="settings.myRegisterCodeDesc"
-      onClick={openRenewDialog}
+      onClick={() => setDialogOpen(true)}
     >
       {registerCode === undefined ? (
         <Spinner />
@@ -181,13 +180,59 @@ function RegisterCodeSettingItem({
           {registerCode}
         </code>
       )}
+
+      <ConfirmDialog
+        title="settings.renewRegisterCode"
+        body="settings.renewRegisterCodeDesc"
+        onClose={() => setDialogOpen(false)}
+        open={dialogOpen}
+        onConfirm={() => {
+          if (user == null) throw new Error();
+          void getHttpUserClient()
+            .renewRegisterCode(user.username)
+            .then(() => {
+              setRegisterCode(undefined);
+            });
+        }}
+      />
     </SettingItemContainer>
   );
 }
 
-export default function SettingsPage() {
-  const c = useC();
+function LanguageChangeSettingItem() {
   const { i18n } = useTranslation();
+
+  const language = i18n.language.slice(0, 2);
+
+  return (
+    <SelectSettingsItem
+      title="settings.languagePrimary"
+      description="settings.languageSecondary"
+      options={[
+        {
+          value: "zh",
+          label: {
+            type: "custom",
+            value: "中文",
+          },
+        },
+        {
+          value: "en",
+          label: {
+            type: "custom",
+            value: "English",
+          },
+        },
+      ]}
+      value={language}
+      onSelect={(value) => {
+        void i18n.changeLanguage(value);
+      }}
+    />
+  );
+}
+
+export default function SettingPage() {
   const user = useUser();
   const navigate = useNavigate();
 
@@ -204,66 +249,36 @@ export default function SettingsPage() {
     return () => setDialog(name);
   }
 
-  const language = i18n.language.slice(0, 2);
-
   return (
-    <>
-      <div className="container">
-        {user ? (
-          <SettingSection title="settings.subheader.account">
-            <RegisterCodeSettingItem
-              openRenewDialog={dialogOpener("renew-register-code")}
-            />
-            <ButtonSettingItem
-              title="settings.changeAvatar"
-              onClick={dialogOpener("change-avatar")}
-            />
-            <ButtonSettingItem
-              title="settings.changeNickname"
-              onClick={dialogOpener("change-nickname")}
-            />
-            <ButtonSettingItem
-              title="settings.changePassword"
-              onClick={dialogOpener("change-password")}
-              danger
-            />
-            <ButtonSettingItem
-              title="settings.logout"
-              onClick={dialogOpener("logout")}
-              danger
-            />
-          </SettingSection>
-        ) : null}
-        <SettingSection title="settings.subheaders.customization">
-          <SelectSettingsItem
-            title="settings.languagePrimary"
-            subtext="settings.languageSecondary"
-            options={[
-              {
-                value: "zh",
-                label: {
-                  type: "custom",
-                  value: "中文",
-                },
-              },
-              {
-                value: "en",
-                label: {
-                  type: "custom",
-                  value: "English",
-                },
-              },
-            ]}
-            value={language}
-            onSelect={(value) => {
-              void i18n.changeLanguage(value);
-            }}
-            first
+    <Page noTopPadding>
+      {user ? (
+        <SettingSection title="settings.subheader.account">
+          <RegisterCodeSettingItem />
+          <ButtonSettingItem
+            title="settings.changeAvatar"
+            onClick={dialogOpener("change-avatar")}
+          />
+          <ButtonSettingItem
+            title="settings.changeNickname"
+            onClick={dialogOpener("change-nickname")}
+          />
+          <ButtonSettingItem
+            title="settings.changePassword"
+            onClick={dialogOpener("change-password")}
+            danger
+          />
+          <ButtonSettingItem
+            title="settings.logout"
+            onClick={dialogOpener("logout")}
+            danger
           />
         </SettingSection>
-      </div>
+      ) : null}
+      <SettingSection title="settings.subheader.customization">
+        <LanguageChangeSettingItem />
+      </SettingSection>
       <ChangePasswordDialog
-        open={dialog === "changepassword"}
+        open={dialog === "change-password"}
         close={() => setDialog(null)}
       />
       <ConfirmDialog
@@ -277,28 +292,14 @@ export default function SettingsPage() {
           });
         }}
       />
-      <ConfirmDialog
-        title="settings.renewRegisterCode"
-        body="settings.renewRegisterCodeDesc"
-        onClose={() => setDialog(null)}
-        open={dialog === "renewregistercode"}
-        onConfirm={() => {
-          if (user == null) throw new UiLogicError();
-          void getHttpUserClient()
-            .renewRegisterCode(user.username)
-            .then(() => {
-              setRegisterCode(undefined);
-            });
-        }}
-      />
       <ChangeAvatarDialog
-        open={dialog === "changeavatar"}
+        open={dialog === "change-avatar"}
         close={() => setDialog(null)}
       />
       <ChangeNicknameDialog
-        open={dialog === "changenickname"}
+        open={dialog === "change-nickname"}
         close={() => setDialog(null)}
       />
-    </>
+    </Page>
   );
 }
diff --git a/FrontEnd/src/views/common/Card.css b/FrontEnd/src/views/common/Card.css
index 98cb4cdd..5b3dbbe9 100644
--- a/FrontEnd/src/views/common/Card.css
+++ b/FrontEnd/src/views/common/Card.css
@@ -1,9 +1,11 @@
 .cru-card {
+  border: solid 1px;
   border-radius: var(--cru-card-border-radius);
-  background-color: var(--cru-primary-container-color);
+  background-color: var(--cru-key-container-color);
+  border-color: var(--cru-key-container-color);
   transition: all 0.3s;
 }
 
 .cru-card:hover {
-  border-color: var(--cru-primary-1-color);
-}
+  border-color: var(--cru-key-1-color);
+}
\ No newline at end of file
diff --git a/FrontEnd/src/views/common/Card.tsx b/FrontEnd/src/views/common/Card.tsx
index 5ff89b61..35e605af 100644
--- a/FrontEnd/src/views/common/Card.tsx
+++ b/FrontEnd/src/views/common/Card.tsx
@@ -1,22 +1,26 @@
 import { ComponentPropsWithoutRef, Ref } from "react";
 import classNames from "classnames";
 
+import { ThemeColor } from "./common";
 import "./Card.css";
 
 interface CardProps extends ComponentPropsWithoutRef<"div"> {
-  containerRef?: Ref<HTMLDivElement> | null;
+  containerRef?: Ref<HTMLDivElement>;
+  color?: ThemeColor;
 }
 
 export default function Card({
+  color,
   className,
   children,
   containerRef,
   ...otherProps
 }: CardProps) {
+  color = color ?? "primary";
   return (
     <div
       ref={containerRef}
-      className={classNames("cru-card", className)}
+      className={classNames("cru-card", `cru-${color}`, className)}
       {...otherProps}
     >
       {children}
diff --git a/FrontEnd/src/views/common/Page.tsx b/FrontEnd/src/views/common/Page.tsx
new file mode 100644
index 00000000..86fdb2f5
--- /dev/null
+++ b/FrontEnd/src/views/common/Page.tsx
@@ -0,0 +1,15 @@
+import { ComponentPropsWithoutRef, Ref } from "react";
+import classNames from "classnames";
+
+interface PageProps extends ComponentPropsWithoutRef<"div"> {
+  noTopPadding?: boolean;
+  pageRef?: Ref<HTMLDivElement>;
+}
+
+export default function Page({ noTopPadding, pageRef, className, children }: PageProps) {
+  return (
+    <div ref={pageRef} className={classNames(className, "cru-page", noTopPadding && "cru-page-no-top-padding")}>
+      {children}
+    </div>
+  );
+}
diff --git a/FrontEnd/src/views/common/index.css b/FrontEnd/src/views/common/index.css
index eb82c4bf..1f9b5086 100644
--- a/FrontEnd/src/views/common/index.css
+++ b/FrontEnd/src/views/common/index.css
@@ -13,6 +13,14 @@ body {
   line-height: 1.2;
 }
 
+.cru-page {
+  padding: var(--cru-page-padding);
+}
+
+.cru-page-no-top-padding {
+  padding-top: 0;
+}
+
 .cru-text-center {
   text-align: center;
 }
diff --git a/FrontEnd/src/views/common/theme.css b/FrontEnd/src/views/common/theme.css
index 3ad45996..6478ed7a 100644
--- a/FrontEnd/src/views/common/theme.css
+++ b/FrontEnd/src/views/common/theme.css
@@ -2,5 +2,8 @@
 
 :root {
   --cru-default-font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
+  --cru-page-padding: 1em 2em;
   --cru-card-border-radius: 4px;
-}
\ No newline at end of file
+
+  --cru-secondary-text-color: var(--cru-surface-on-color);
+}
diff --git a/FrontEnd/src/views/settings/ChangeAvatarDialog.tsx b/FrontEnd/src/views/settings/ChangeAvatarDialog.tsx
deleted file mode 100644
index 44bd2c68..00000000
--- a/FrontEnd/src/views/settings/ChangeAvatarDialog.tsx
+++ /dev/null
@@ -1,354 +0,0 @@
-import { useState, useEffect } from "react";
-import * as React from "react";
-import { useTranslation } from "react-i18next";
-import { AxiosError } from "axios";
-
-import { convertI18nText, I18nText, UiLogicError } from "@/common";
-
-import { useUser } from "@/services/user";
-
-import { getHttpUserClient } from "@/http/user";
-
-import ImageCropper, { Clip, applyClipToImage } from "../common/ImageCropper";
-import Button from "../common/button/Button";
-import Dialog from "../common/dialog/Dialog";
-
-export interface ChangeAvatarDialogProps {
-  open: boolean;
-  close: () => void;
-}
-
-const ChangeAvatarDialog: React.FC<ChangeAvatarDialogProps> = (props) => {
-  const { t } = useTranslation();
-
-  const user = useUser();
-
-  const [file, setFile] = React.useState<File | null>(null);
-  const [fileUrl, setFileUrl] = React.useState<string | null>(null);
-  const [clip, setClip] = React.useState<Clip | null>(null);
-  const [cropImgElement, setCropImgElement] =
-    React.useState<HTMLImageElement | null>(null);
-  const [resultBlob, setResultBlob] = React.useState<Blob | null>(null);
-  const [resultUrl, setResultUrl] = React.useState<string | null>(null);
-
-  const [state, setState] = React.useState<
-    | "select"
-    | "crop"
-    | "processcrop"
-    | "preview"
-    | "uploading"
-    | "success"
-    | "error"
-  >("select");
-
-  const [message, setMessage] = useState<I18nText>(
-    "settings.dialogChangeAvatar.prompt.select"
-  );
-
-  const trueMessage = convertI18nText(message, t);
-
-  const closeDialog = props.close;
-
-  const close = React.useCallback((): void => {
-    if (!(state === "uploading")) {
-      closeDialog();
-    }
-  }, [state, closeDialog]);
-
-  useEffect(() => {
-    if (file != null) {
-      const url = URL.createObjectURL(file);
-      setClip(null);
-      setFileUrl(url);
-      setState("crop");
-      return () => {
-        URL.revokeObjectURL(url);
-      };
-    } else {
-      setFileUrl(null);
-      setState("select");
-    }
-  }, [file]);
-
-  React.useEffect(() => {
-    if (resultBlob != null) {
-      const url = URL.createObjectURL(resultBlob);
-      setResultUrl(url);
-      setState("preview");
-      return () => {
-        URL.revokeObjectURL(url);
-      };
-    } else {
-      setResultUrl(null);
-    }
-  }, [resultBlob]);
-
-  const onSelectFile = React.useCallback(
-    (e: React.ChangeEvent<HTMLInputElement>): void => {
-      const files = e.target.files;
-      if (files == null || files.length === 0) {
-        setFile(null);
-      } else {
-        setFile(files[0]);
-      }
-    },
-    []
-  );
-
-  const onCropNext = React.useCallback(() => {
-    if (
-      cropImgElement == null ||
-      clip == null ||
-      clip.width === 0 ||
-      file == null
-    ) {
-      throw new UiLogicError();
-    }
-
-    setState("processcrop");
-    void applyClipToImage(cropImgElement, clip, file.type).then((b) => {
-      setResultBlob(b);
-    });
-  }, [cropImgElement, clip, file]);
-
-  const onCropPrevious = React.useCallback(() => {
-    setFile(null);
-    setState("select");
-  }, []);
-
-  const onPreviewPrevious = React.useCallback(() => {
-    setResultBlob(null);
-    setState("crop");
-  }, []);
-
-  const upload = React.useCallback(() => {
-    if (resultBlob == null) {
-      throw new UiLogicError();
-    }
-
-    if (user == null) {
-      throw new UiLogicError();
-    }
-
-    setState("uploading");
-    getHttpUserClient()
-      .putAvatar(user.username, resultBlob)
-      .then(
-        () => {
-          setState("success");
-        },
-        (e: unknown) => {
-          setState("error");
-          setMessage({ type: "custom", value: (e as AxiosError).message });
-        }
-      );
-  }, [user, resultBlob]);
-
-  const createPreviewRow = (): React.ReactElement => {
-    if (resultUrl == null) {
-      throw new UiLogicError();
-    }
-    return (
-      <div className="row justify-content-center">
-        <div className="col col-auto">
-          <img
-            className="change-avatar-img"
-            src={resultUrl}
-            alt={t("settings.dialogChangeAvatar.previewImgAlt") ?? undefined}
-          />
-        </div>
-      </div>
-    );
-  };
-
-  return (
-    <Dialog open={props.open} onClose={close}>
-      <h3 className="cru-color-primary">
-        {t("settings.dialogChangeAvatar.title")}
-      </h3>
-      <hr />
-      {(() => {
-        if (state === "select") {
-          return (
-            <>
-              <div className="container">
-                <div className="row">
-                  {t("settings.dialogChangeAvatar.prompt.select")}
-                </div>
-                <div className="row">
-                  <input
-                    className="px-0"
-                    type="file"
-                    accept="image/*"
-                    onChange={onSelectFile}
-                  />
-                </div>
-              </div>
-              <hr />
-              <div className="cru-dialog-bottom-area">
-                <Button
-                  text="operationDialog.cancel"
-                  color="secondary"
-                  onClick={close}
-                />
-              </div>
-            </>
-          );
-        } else if (state === "crop") {
-          if (fileUrl == null) {
-            throw new UiLogicError();
-          }
-          return (
-            <>
-              <div className="container">
-                <div className="row justify-content-center">
-                  <ImageCropper
-                    clip={clip}
-                    onChange={setClip}
-                    imageUrl={fileUrl}
-                    imageElementCallback={setCropImgElement}
-                  />
-                </div>
-                <div className="row">
-                  {t("settings.dialogChangeAvatar.prompt.crop")}
-                </div>
-              </div>
-              <hr />
-              <div className="cru-dialog-bottom-area">
-                <Button
-                  text="operationDialog.cancel"
-                  color="secondary"
-                  outline
-                  onClick={close}
-                />
-                <Button
-                  text="operationDialog.previousStep"
-                  color="secondary"
-                  outline
-                  onClick={onCropPrevious}
-                />
-                <Button
-                  text="operationDialog.nextStep"
-                  color="primary"
-                  onClick={onCropNext}
-                  disabled={
-                    cropImgElement == null || clip == null || clip.width === 0
-                  }
-                />
-              </div>
-            </>
-          );
-        } else if (state === "processcrop") {
-          return (
-            <>
-              <div className="container">
-                <div className="row">
-                  {t("settings.dialogChangeAvatar.prompt.processingCrop")}
-                </div>
-              </div>
-              <hr />
-              <div className="cru-dialog-bottom-area">
-                <Button
-                  text="operationDialog.cancel"
-                  color="secondary"
-                  onClick={close}
-                  outline
-                />
-                <Button
-                  text="operationDialog.previousStep"
-                  color="secondary"
-                  onClick={onPreviewPrevious}
-                  outline
-                />
-              </div>
-            </>
-          );
-        } else if (state === "preview") {
-          return (
-            <>
-              <div className="container">
-                {createPreviewRow()}
-                <div className="row">
-                  {t("settings.dialogChangeAvatar.prompt.preview")}
-                </div>
-              </div>
-              <hr />
-              <div className="cru-dialog-bottom-area">
-                <Button
-                  text="operationDialog.cancel"
-                  color="secondary"
-                  outline
-                  onClick={close}
-                />
-                <Button
-                  text="operationDialog.previousStep"
-                  color="secondary"
-                  outline
-                  onClick={onPreviewPrevious}
-                />
-                <Button
-                  text="settings.dialogChangeAvatar.upload"
-                  color="primary"
-                  onClick={upload}
-                />
-              </div>
-            </>
-          );
-        } else if (state === "uploading") {
-          return (
-            <>
-              <div className="container">
-                {createPreviewRow()}
-                <div className="row">
-                  {t("settings.dialogChangeAvatar.prompt.uploading")}
-                </div>
-              </div>
-            </>
-          );
-        } else if (state === "success") {
-          return (
-            <>
-              <div className="container">
-                <div className="row p-4 text-success">
-                  {t("operationDialog.success")}
-                </div>
-              </div>
-              <hr />
-              <div className="cru-dialog-bottom-area">
-                <Button
-                  text="operationDialog.ok"
-                  color="success"
-                  onClick={close}
-                />
-              </div>
-            </>
-          );
-        } else {
-          return (
-            <>
-              <div className="container">
-                {createPreviewRow()}
-                <div className="row text-danger">{trueMessage}</div>
-              </div>
-              <hr />
-              <div>
-                <Button
-                  text="operationDialog.cancel"
-                  color="secondary"
-                  onClick={close}
-                />
-                <Button
-                  text="operationDialog.retry"
-                  color="primary"
-                  onClick={upload}
-                />
-              </div>
-            </>
-          );
-        }
-      })()}
-    </Dialog>
-  );
-};
-
-export default ChangeAvatarDialog;
diff --git a/FrontEnd/src/views/settings/ChangeNicknameDialog.tsx b/FrontEnd/src/views/settings/ChangeNicknameDialog.tsx
deleted file mode 100644
index 7ba12de8..00000000
--- a/FrontEnd/src/views/settings/ChangeNicknameDialog.tsx
+++ /dev/null
@@ -1,34 +0,0 @@
-import { getHttpUserClient } from "@/http/user";
-import { useUser } from "@/services/user";
-import * as React from "react";
-
-import OperationDialog from "../common/dialog/OperationDialog";
-
-export interface ChangeNicknameDialogProps {
-  open: boolean;
-  close: () => void;
-}
-
-const ChangeNicknameDialog: React.FC<ChangeNicknameDialogProps> = (props) => {
-  const user = useUser();
-
-  if (user == null) return null;
-
-  return (
-    <OperationDialog
-      open={props.open}
-      title="settings.dialogChangeNickname.title"
-      inputScheme={[
-        { type: "text", label: "settings.dialogChangeNickname.inputLabel" },
-      ]}
-      onProcess={([newNickname]) => {
-        return getHttpUserClient().patch(user.username, {
-          nickname: newNickname,
-        });
-      }}
-      onClose={props.close}
-    />
-  );
-};
-
-export default ChangeNicknameDialog;
diff --git a/FrontEnd/src/views/settings/ChangePasswordDialog.tsx b/FrontEnd/src/views/settings/ChangePasswordDialog.tsx
deleted file mode 100644
index a34ca4a7..00000000
--- a/FrontEnd/src/views/settings/ChangePasswordDialog.tsx
+++ /dev/null
@@ -1,69 +0,0 @@
-import { useState } from "react";
-import * as React from "react";
-import { useNavigate } from "react-router-dom";
-
-import { userService } from "@/services/user";
-
-import OperationDialog from "../common/dialog/OperationDialog";
-
-export interface ChangePasswordDialogProps {
-  open: boolean;
-  close: () => void;
-}
-
-const ChangePasswordDialog: React.FC<ChangePasswordDialogProps> = (props) => {
-  const navigate = useNavigate();
-
-  const [redirect, setRedirect] = useState<boolean>(false);
-
-  return (
-    <OperationDialog
-      open={props.open}
-      title="settings.dialogChangePassword.title"
-      themeColor="danger"
-      inputPrompt="settings.dialogChangePassword.prompt"
-      inputScheme={[
-        {
-          type: "text",
-          label: "settings.dialogChangePassword.inputOldPassword",
-          password: true,
-        },
-        {
-          type: "text",
-          label: "settings.dialogChangePassword.inputNewPassword",
-          password: true,
-        },
-        {
-          type: "text",
-          label: "settings.dialogChangePassword.inputRetypeNewPassword",
-          password: true,
-        },
-      ]}
-      inputValidator={([oldPassword, newPassword, retypedNewPassword]) => {
-        const result: Record<number, string> = {};
-        if (oldPassword === "") {
-          result[0] = "settings.dialogChangePassword.errorEmptyOldPassword";
-        }
-        if (newPassword === "") {
-          result[1] = "settings.dialogChangePassword.errorEmptyNewPassword";
-        }
-        if (retypedNewPassword !== newPassword) {
-          result[2] = "settings.dialogChangePassword.errorRetypeNotMatch";
-        }
-        return result;
-      }}
-      onProcess={async ([oldPassword, newPassword]) => {
-        await userService.changePassword(oldPassword, newPassword);
-        setRedirect(true);
-      }}
-      onClose={() => {
-        props.close();
-        if (redirect) {
-          navigate("/login");
-        }
-      }}
-    />
-  );
-};
-
-export default ChangePasswordDialog;
diff --git a/FrontEnd/src/views/settings/index.css b/FrontEnd/src/views/settings/index.css
deleted file mode 100644
index ccf7a97a..00000000
--- a/FrontEnd/src/views/settings/index.css
+++ /dev/null
@@ -1,31 +0,0 @@
-.change-avatar-cropper-row {
-  max-height: 400px;
-}
-
-.change-avatar-img {
-  min-width: 50%;
-  max-width: 100%;
-  max-height: 400px;
-}
-
-.settings-item {
-  padding: 0.5em 1em;
-  transition: background 0.3s;
-  border-bottom: 1px solid #e9ecef;
-  align-items: center;
-}
-.settings-item.first {
-  border-top: 1px solid #e9ecef;
-}
-.settings-item.clickable {
-  cursor: pointer;
-}
-.settings-item:hover {
-  background: #dee2e6;
-}
-
-.register-code {
-  border: 1px solid black;
-  border-radius: 3px;
-  padding: 0.2em;
-}
\ No newline at end of file
diff --git a/FrontEnd/src/views/settings/index.tsx b/FrontEnd/src/views/settings/index.tsx
deleted file mode 100644
index 6647826f..00000000
--- a/FrontEnd/src/views/settings/index.tsx
+++ /dev/null
@@ -1,338 +0,0 @@
-import { useState } from "react";
-import * as React from "react";
-import { useNavigate } from "react-router-dom";
-import { useTranslation } from "react-i18next";
-import classNames from "classnames";
-
-import { convertI18nText, I18nText, UiLogicError } from "@/common";
-import { useUser, userService } from "@/services/user";
-import { getHttpUserClient } from "@/http/user";
-import { TimelineVisibility } from "@/http/timeline";
-
-import ConfirmDialog from "../common/dialog/ConfirmDialog";
-import Card from "../common/Card";
-import Spinner from "../common/Spinner";
-import ChangePasswordDialog from "./ChangePasswordDialog";
-import ChangeAvatarDialog from "./ChangeAvatarDialog";
-import ChangeNicknameDialog from "./ChangeNicknameDialog";
-
-import "./index.css";
-import { pushAlert } from "@/services/alert";
-
-interface SettingSectionProps {
-  title: I18nText;
-  children: React.ReactNode;
-}
-
-const SettingSection: React.FC<SettingSectionProps> = ({ title, children }) => {
-  const { t } = useTranslation();
-
-  return (
-    <Card className="my-3 py-3">
-      <h3 className="px-3 mb-3 cru-color-primary">
-        {convertI18nText(title, t)}
-      </h3>
-      {children}
-    </Card>
-  );
-};
-
-interface SettingItemContainerWithoutChildrenProps {
-  title: I18nText;
-  subtext?: I18nText;
-  first?: boolean;
-  danger?: boolean;
-  style?: React.CSSProperties;
-  className?: string;
-  onClick?: () => void;
-}
-
-interface SettingItemContainerProps
-  extends SettingItemContainerWithoutChildrenProps {
-  children?: React.ReactNode;
-}
-
-function SettingItemContainer({
-  title,
-  subtext,
-  first,
-  danger,
-  children,
-  style,
-  className,
-  onClick,
-}: SettingItemContainerProps): JSX.Element {
-  const { t } = useTranslation();
-
-  return (
-    <div
-      style={style}
-      className={classNames(
-        "row settings-item mx-0",
-        first && "first",
-        onClick && "clickable",
-        className,
-      )}
-      onClick={onClick}
-    >
-      <div className="px-0 col col-auto">
-        <div className={classNames(danger && "cru-color-danger")}>
-          {convertI18nText(title, t)}
-        </div>
-        <small className="d-block cru-color-secondary">
-          {convertI18nText(subtext, t)}
-        </small>
-      </div>
-      <div className="col col-auto">{children}</div>
-    </div>
-  );
-}
-
-type ButtonSettingItemProps = SettingItemContainerWithoutChildrenProps;
-
-const ButtonSettingItem: React.FC<ButtonSettingItemProps> = ({ ...props }) => {
-  return <SettingItemContainer {...props} />;
-};
-
-interface SelectSettingItemProps
-  extends SettingItemContainerWithoutChildrenProps {
-  options: {
-    value: string;
-    label: I18nText;
-  }[];
-  value?: string;
-  onSelect: (value: string) => void;
-}
-
-const SelectSettingsItem: React.FC<SelectSettingItemProps> = ({
-  options,
-  value,
-  onSelect,
-  ...props
-}) => {
-  const { t } = useTranslation();
-
-  return (
-    <SettingItemContainer {...props}>
-      {value == null ? (
-        <Spinner />
-      ) : (
-        <select
-          value={value}
-          onChange={(e) => {
-            onSelect(e.target.value);
-          }}
-        >
-          {options.map(({ value, label }) => (
-            <option key={value} value={value}>
-              {convertI18nText(label, t)}
-            </option>
-          ))}
-        </select>
-      )}
-    </SettingItemContainer>
-  );
-};
-
-const SettingsPage: React.FC = () => {
-  const { i18n } = useTranslation();
-  const user = useUser();
-  const navigate = useNavigate();
-
-  const [dialog, setDialog] = useState<
-    | null
-    | "changepassword"
-    | "changeavatar"
-    | "changenickname"
-    | "logout"
-    | "renewregistercode"
-  >(null);
-
-  const [registerCode, setRegisterCode] = useState<undefined | null | string>(
-    undefined,
-  );
-
-  const [bookmarkVisibility, setBookmarkVisibility] =
-    useState<TimelineVisibility>();
-
-  React.useEffect(() => {
-    if (user != null) {
-      void getHttpUserClient()
-        .getBookmarkVisibility(user.username)
-        .then(({ visibility }) => {
-          setBookmarkVisibility(visibility);
-        });
-    } else {
-      setBookmarkVisibility(undefined);
-    }
-  }, [user]);
-
-  React.useEffect(() => {
-    setRegisterCode(undefined);
-  }, [user]);
-
-  React.useEffect(() => {
-    if (user != null && registerCode === undefined) {
-      void getHttpUserClient()
-        .getRegisterCode(user.username)
-        .then((code) => {
-          setRegisterCode(code.registerCode ?? null);
-        });
-    }
-  }, [user, registerCode]);
-
-  const language = i18n.language.slice(0, 2);
-
-  return (
-    <>
-      <div className="container">
-        {user ? (
-          <SettingSection title="settings.subheaders.account">
-            <SettingItemContainer
-              title="settings.myRegisterCode"
-              subtext="settings.myRegisterCodeDesc"
-              onClick={() => setDialog("renewregistercode")}
-            >
-              {registerCode === undefined ? (
-                <Spinner />
-              ) : registerCode === null ? (
-                <span>Noop</span>
-              ) : (
-                <code
-                  className="register-code"
-                  onClick={(event) => {
-                    void navigator.clipboard
-                      .writeText(registerCode)
-                      .then(() => {
-                        pushAlert({
-                          type: "success",
-                          message: "settings.myRegisterCodeCopied",
-                        });
-                      });
-                    event.stopPropagation();
-                  }}
-                >
-                  {registerCode}
-                </code>
-              )}
-            </SettingItemContainer>
-            <ButtonSettingItem
-              title="settings.changeAvatar"
-              onClick={() => setDialog("changeavatar")}
-              first
-            />
-            <ButtonSettingItem
-              title="settings.changeNickname"
-              onClick={() => setDialog("changenickname")}
-            />
-            <SelectSettingsItem
-              title="settings.changeBookmarkVisibility"
-              options={[
-                {
-                  value: "Private",
-                  label: "visibility.private",
-                },
-                {
-                  value: "Register",
-                  label: "visibility.register",
-                },
-                {
-                  value: "Public",
-                  label: "visibility.public",
-                },
-              ]}
-              value={bookmarkVisibility}
-              onSelect={(value) => {
-                void getHttpUserClient()
-                  .putBookmarkVisibility(user.username, {
-                    visibility: value as TimelineVisibility,
-                  })
-                  .then(() => {
-                    setBookmarkVisibility(value as TimelineVisibility);
-                  });
-              }}
-            />
-            <ButtonSettingItem
-              title="settings.changePassword"
-              onClick={() => setDialog("changepassword")}
-              danger
-            />
-            <ButtonSettingItem
-              title="settings.logout"
-              onClick={() => {
-                setDialog("logout");
-              }}
-              danger
-            />
-          </SettingSection>
-        ) : null}
-        <SettingSection title="settings.subheaders.customization">
-          <SelectSettingsItem
-            title="settings.languagePrimary"
-            subtext="settings.languageSecondary"
-            options={[
-              {
-                value: "zh",
-                label: {
-                  type: "custom",
-                  value: "中文",
-                },
-              },
-              {
-                value: "en",
-                label: {
-                  type: "custom",
-                  value: "English",
-                },
-              },
-            ]}
-            value={language}
-            onSelect={(value) => {
-              void i18n.changeLanguage(value);
-            }}
-            first
-          />
-        </SettingSection>
-      </div>
-      <ChangePasswordDialog
-        open={dialog === "changepassword"}
-        close={() => setDialog(null)}
-      />
-      <ConfirmDialog
-        title="settings.dialogConfirmLogout.title"
-        body="settings.dialogConfirmLogout.prompt"
-        onClose={() => setDialog(null)}
-        open={dialog === "logout"}
-        onConfirm={() => {
-          void userService.logout().then(() => {
-            navigate("/");
-          });
-        }}
-      />
-      <ConfirmDialog
-        title="settings.renewRegisterCode"
-        body="settings.renewRegisterCodeDesc"
-        onClose={() => setDialog(null)}
-        open={dialog === "renewregistercode"}
-        onConfirm={() => {
-          if (user == null) throw new UiLogicError();
-          void getHttpUserClient()
-            .renewRegisterCode(user.username)
-            .then(() => {
-              setRegisterCode(undefined);
-            });
-        }}
-      />
-      <ChangeAvatarDialog
-        open={dialog === "changeavatar"}
-        close={() => setDialog(null)}
-      />
-      <ChangeNicknameDialog
-        open={dialog === "changenickname"}
-        close={() => setDialog(null)}
-      />
-    </>
-  );
-};
-
-export default SettingsPage;
-- 
cgit v1.2.3


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

---
 FrontEnd/src/App.tsx                           |   2 +-
 FrontEnd/src/pages/login/index.css             |  10 ++
 FrontEnd/src/pages/login/index.tsx             | 133 +++++++++++++++++++++++++
 FrontEnd/src/views/common/input/InputGroup.tsx |  23 ++++-
 4 files changed, 163 insertions(+), 5 deletions(-)
 create mode 100644 FrontEnd/src/pages/login/index.css
 create mode 100644 FrontEnd/src/pages/login/index.tsx

(limited to 'FrontEnd/src/App.tsx')

diff --git a/FrontEnd/src/App.tsx b/FrontEnd/src/App.tsx
index 92fe0652..8f2bf6b0 100644
--- a/FrontEnd/src/App.tsx
+++ b/FrontEnd/src/App.tsx
@@ -7,7 +7,7 @@ import LoadingPage from "./views/common/LoadingPage";
 import AboutPage from "./pages/about";
 import SettingPage from "./pages/setting";
 import Center from "./views/center";
-import Login from "./views/login";
+import Login from "./pages/login";
 import Register from "./views/register";
 import TimelinePage from "./views/timeline";
 import Search from "./views/search";
diff --git a/FrontEnd/src/pages/login/index.css b/FrontEnd/src/pages/login/index.css
new file mode 100644
index 00000000..d78b3587
--- /dev/null
+++ b/FrontEnd/src/pages/login/index.css
@@ -0,0 +1,10 @@
+.login-page {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+
+.login-page-welcome {
+  text-align: center;
+  font-size: 2em;
+}
\ No newline at end of file
diff --git a/FrontEnd/src/pages/login/index.tsx b/FrontEnd/src/pages/login/index.tsx
new file mode 100644
index 00000000..9aee455f
--- /dev/null
+++ b/FrontEnd/src/pages/login/index.tsx
@@ -0,0 +1,133 @@
+import { useState, useEffect } from "react";
+import { Link, useNavigate } from "react-router-dom";
+import { Trans } from "react-i18next";
+
+import { useUser, userService } from "@/services/user";
+
+import { useC } from "@/views/common/common";
+import LoadingButton from "@/views/common/button/LoadingButton";
+import {
+  InputErrorDict,
+  InputGroup,
+  useInputs,
+} from "@/views/common/input/InputGroup";
+import Page from "@/views/common/Page";
+
+import "./index.css";
+
+export default function LoginPage() {
+  const c = useC();
+
+  const user = useUser();
+
+  const navigate = useNavigate();
+
+  const [process, setProcess] = useState<boolean>(false);
+  const [error, setError] = useState<string | null>(null);
+
+  const { hasErrorAndDirty, confirm, setAllDisabled, inputGroupProps } =
+    useInputs({
+      init: {
+        scheme: {
+          inputs: [
+            {
+              key: "username",
+              type: "text",
+              label: "user.username",
+            },
+            {
+              key: "password",
+              type: "text",
+              label: "user.password",
+              password: true,
+            },
+            {
+              key: "rememberMe",
+              type: "bool",
+              label: "user.rememberMe",
+            },
+          ],
+          validator: ({ username, password }) => {
+            const result: InputErrorDict = {};
+            if (username === "") {
+              result["username"] = "login.emptyUsername";
+            }
+            if (password === "") {
+              result["password"] = "login.emptyPassword";
+            }
+            return result;
+          },
+        },
+        dataInit: {},
+      },
+    });
+
+  useEffect(() => {
+    if (user != null) {
+      const id = setTimeout(() => navigate("/"), 3000);
+      return () => {
+        clearTimeout(id);
+      };
+    }
+  }, [navigate, user]);
+
+  if (user != null) {
+    return <p>{c("login.alreadyLogin")}</p>;
+  }
+
+  const submit = (): void => {
+    const confirmResult = confirm();
+    if (confirmResult.type === "ok") {
+      const { username, password, rememberMe } = confirmResult.values;
+      setAllDisabled(true);
+      setProcess(true);
+      userService
+        .login(
+          {
+            username: username as string,
+            password: password as string,
+          },
+          rememberMe as boolean,
+        )
+        .then(
+          () => {
+            if (history.length === 0) {
+              navigate("/");
+            } else {
+              navigate(-1);
+            }
+          },
+          (e: Error) => {
+            setProcess(false);
+            setAllDisabled(false);
+            setError(e.message);
+          },
+        );
+    }
+  };
+
+  return (
+    <Page className="login-page">
+      <div className="login-page-container">
+        <div className="login-page-welcome">{c("welcome")}</div>
+        <InputGroup {...inputGroupProps} />
+        {error ? <p className="cru-color-danger">{c(error)}</p> : null}
+        <div className="login-page-button-row">
+          <LoadingButton
+            loading={process}
+            onClick={(e) => {
+              submit();
+              e.preventDefault();
+            }}
+            disabled={hasErrorAndDirty}
+          >
+            {c("user.login")}
+          </LoadingButton>
+        </div>
+        <Trans i18nKey="login.noAccount">
+          0<Link to="/register">1</Link>2
+        </Trans>
+      </div>
+    </Page>
+  );
+}
diff --git a/FrontEnd/src/views/common/input/InputGroup.tsx b/FrontEnd/src/views/common/input/InputGroup.tsx
index 3d1e3ada..ee89b05c 100644
--- a/FrontEnd/src/views/common/input/InputGroup.tsx
+++ b/FrontEnd/src/views/common/input/InputGroup.tsx
@@ -23,7 +23,7 @@
  * `useInputs` hook takes care of logic and generate props for `InputGroup`.
  */
 
-import { useState, Ref } from "react";
+import { useState, Ref, useId } from "react";
 import classNames from "classnames";
 
 import { useC, Text, ThemeColor } from "../common";
@@ -332,6 +332,8 @@ export function InputGroup({
 }: InputGroupProps) {
   const c = useC();
 
+  const id = useId();
+
   return (
     <div
       ref={containerRef}
@@ -357,6 +359,8 @@ export function InputGroup({
           onChange(index, value);
         };
 
+        const inputId = `${id}-${key}`;
+
         if (type === "text") {
           const { password } = item;
           return (
@@ -364,8 +368,13 @@ export function InputGroup({
               key={key}
               className={getContainerClassName(password && "password")}
             >
-              {label && <label className="cru-input-label">{c(label)}</label>}
+              {label && (
+                <label className="cru-input-label" htmlFor={inputId}>
+                  {c(label)}
+                </label>
+              )}
               <input
+                id={inputId}
                 type={password ? "password" : "text"}
                 value={value}
                 onChange={(event) => {
@@ -382,6 +391,7 @@ export function InputGroup({
           return (
             <div key={key} className={getContainerClassName()}>
               <input
+                id={inputId}
                 type="checkbox"
                 checked={value}
                 onChange={(event) => {
@@ -390,7 +400,9 @@ export function InputGroup({
                 }}
                 disabled={disabled}
               />
-              <label className="cru-input-label-inline">{c(label)}</label>
+              <label className="cru-input-label-inline" htmlFor={inputId}>
+                {c(label)}
+              </label>
               {error && <div className="cru-input-error">{c(error)}</div>}
               {helper && <div className="cru-input-helper">{c(helper)}</div>}
             </div>
@@ -398,8 +410,11 @@ export function InputGroup({
         } else if (type === "select") {
           return (
             <div key={key} className={getContainerClassName()}>
-              <label className="cru-input-label">{c(label)}</label>
+              <label className="cru-input-label" htmlFor={inputId}>
+                {c(label)}
+              </label>
               <select
+                id={inputId}
                 value={value}
                 onChange={(event) => {
                   const e = event.target.value;
-- 
cgit v1.2.3


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

---
 FrontEnd/src/App.tsx                  |   8 +-
 FrontEnd/src/pages/login/index.css    |   4 +
 FrontEnd/src/pages/login/index.tsx    |   2 +-
 FrontEnd/src/pages/register/index.css |   5 ++
 FrontEnd/src/pages/register/index.tsx | 138 +++++++++++++++++++++++++++++
 FrontEnd/src/views/login/index.css    |   8 --
 FrontEnd/src/views/login/index.tsx    | 159 ----------------------------------
 7 files changed, 152 insertions(+), 172 deletions(-)
 create mode 100644 FrontEnd/src/pages/register/index.css
 create mode 100644 FrontEnd/src/pages/register/index.tsx
 delete mode 100644 FrontEnd/src/views/login/index.css
 delete mode 100644 FrontEnd/src/views/login/index.tsx

(limited to 'FrontEnd/src/App.tsx')

diff --git a/FrontEnd/src/App.tsx b/FrontEnd/src/App.tsx
index 8f2bf6b0..f638f5e8 100644
--- a/FrontEnd/src/App.tsx
+++ b/FrontEnd/src/App.tsx
@@ -7,8 +7,8 @@ import LoadingPage from "./views/common/LoadingPage";
 import AboutPage from "./pages/about";
 import SettingPage from "./pages/setting";
 import Center from "./views/center";
-import Login from "./pages/login";
-import Register from "./views/register";
+import LoginPage from "./pages/login";
+import RegisterPage from "./pages/register";
 import TimelinePage from "./views/timeline";
 import Search from "./views/search";
 import Admin from "./views/admin";
@@ -22,8 +22,8 @@ export default function App() {
         <div style={{ height: 56 }} />
         <Routes>
           <Route path="center" element={<Center />} />
-          <Route path="login" element={<Login />} />
-          <Route path="register" element={<Register />} />
+          <Route path="login" element={<LoginPage />} />
+          <Route path="register" element={<RegisterPage />} />
           <Route path="settings" element={<SettingPage />} />
           <Route path="about" element={<AboutPage />} />
           <Route path="search" element={<Search />} />
diff --git a/FrontEnd/src/pages/login/index.css b/FrontEnd/src/pages/login/index.css
index d78b3587..ef97359c 100644
--- a/FrontEnd/src/pages/login/index.css
+++ b/FrontEnd/src/pages/login/index.css
@@ -7,4 +7,8 @@
 .login-page-welcome {
   text-align: center;
   font-size: 2em;
+}
+
+.login-page-error {
+  color: var(--cru-danger-color);
 }
\ No newline at end of file
diff --git a/FrontEnd/src/pages/login/index.tsx b/FrontEnd/src/pages/login/index.tsx
index 9aee455f..a09e32c3 100644
--- a/FrontEnd/src/pages/login/index.tsx
+++ b/FrontEnd/src/pages/login/index.tsx
@@ -111,7 +111,7 @@ export default function LoginPage() {
       <div className="login-page-container">
         <div className="login-page-welcome">{c("welcome")}</div>
         <InputGroup {...inputGroupProps} />
-        {error ? <p className="cru-color-danger">{c(error)}</p> : null}
+        {error ? <p className="login-page-error">{c(error)}</p> : null}
         <div className="login-page-button-row">
           <LoadingButton
             loading={process}
diff --git a/FrontEnd/src/pages/register/index.css b/FrontEnd/src/pages/register/index.css
new file mode 100644
index 00000000..c0078b28
--- /dev/null
+++ b/FrontEnd/src/pages/register/index.css
@@ -0,0 +1,5 @@
+.register-page {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
diff --git a/FrontEnd/src/pages/register/index.tsx b/FrontEnd/src/pages/register/index.tsx
new file mode 100644
index 00000000..bc474adb
--- /dev/null
+++ b/FrontEnd/src/pages/register/index.tsx
@@ -0,0 +1,138 @@
+import { useState, useEffect } from "react";
+import { useTranslation } from "react-i18next";
+import { useNavigate } from "react-router-dom";
+
+import { HttpBadRequestError } from "@/http/common";
+import { getHttpTokenClient } from "@/http/token";
+import { userService, useUser } from "@/services/user";
+
+import { LoadingButton } from "@/views/common/button";
+import {
+  useInputs,
+  InputErrorDict,
+  InputGroup,
+} from "@/views/common/input/InputGroup";
+
+import "./index.css";
+
+export default function RegisterPage() {
+  const navigate = useNavigate();
+
+  const { t } = useTranslation();
+
+  const user = useUser();
+
+  const { hasErrorAndDirty, confirm, setAllDisabled, inputGroupProps } =
+    useInputs({
+      init: {
+        scheme: {
+          inputs: [
+            {
+              key: "username",
+              type: "text",
+              label: "register.username",
+            },
+            {
+              key: "password",
+              type: "text",
+              label: "register.password",
+              password: true,
+            },
+            {
+              key: "confirmPassword",
+              type: "text",
+              label: "register.confirmPassword",
+              password: true,
+            },
+            {
+              key: "registerCode",
+
+              type: "text",
+              label: "register.registerCode",
+            },
+          ],
+          validator: ({
+            username,
+            password,
+            confirmPassword,
+            registerCode,
+          }) => {
+            const result: InputErrorDict = {};
+            if (username === "") {
+              result["username"] = "register.error.usernameEmpty";
+            }
+            if (password === "") {
+              result["password"] = "register.error.passwordEmpty";
+            }
+            if (confirmPassword !== password) {
+              result["confirmPassword"] = "register.error.confirmPasswordWrong";
+            }
+            if (registerCode === "") {
+              result["registerCode"] = "register.error.registerCodeEmpty";
+            }
+            return result;
+          },
+        },
+        dataInit: {},
+      },
+    });
+
+  const [process, setProcess] = useState<boolean>(false);
+  const [resultError, setResultError] = useState<string | null>(null);
+
+  useEffect(() => {
+    if (user != null) {
+      navigate("/");
+    }
+  }, [navigate, user]);
+
+  return (
+    <div className="container register-page">
+      <InputGroup {...inputGroupProps} />
+      {resultError && <div className="cru-color-danger">{t(resultError)}</div>}
+      <LoadingButton
+        text="register.register"
+        loading={process}
+        disabled={hasErrorAndDirty}
+        onClick={() => {
+          const confirmResult = confirm();
+          if (confirmResult.type === "ok") {
+            const { username, password, registerCode } = confirmResult.values;
+            setProcess(true);
+            setAllDisabled(true);
+            void getHttpTokenClient()
+              .register({
+                username: username as string,
+                password: password as string,
+                registerCode: registerCode as string,
+              })
+              .then(
+                () => {
+                  void userService
+                    .login(
+                      {
+                        username: username as string,
+                        password: password as string,
+                      },
+                      true,
+                    )
+                    .then(() => {
+                      navigate("/");
+                    });
+                },
+                (error) => {
+                  if (error instanceof HttpBadRequestError) {
+                    setResultError("register.error.registerCodeInvalid");
+                  } else {
+                    setResultError("error.network");
+                  }
+                  setProcess(false);
+                  setAllDisabled(false);
+                },
+              );
+          }
+        }}
+      />
+    </div>
+  );
+}
diff --git a/FrontEnd/src/views/login/index.css b/FrontEnd/src/views/login/index.css
deleted file mode 100644
index aefe57e8..00000000
--- a/FrontEnd/src/views/login/index.css
+++ /dev/null
@@ -1,8 +0,0 @@
-.login-container {
-  max-width: 25em;
-}
-
-.login-container input[type="text"],
-.login-container input[type="password"] {
-  width: 100%;
-}
diff --git a/FrontEnd/src/views/login/index.tsx b/FrontEnd/src/views/login/index.tsx
deleted file mode 100644
index cc1d9865..00000000
--- a/FrontEnd/src/views/login/index.tsx
+++ /dev/null
@@ -1,159 +0,0 @@
-import * as React from "react";
-import { Link, useNavigate } from "react-router-dom";
-import { useTranslation, Trans } from "react-i18next";
-
-import { useUser, userService } from "@/services/user";
-
-import AppBar from "../common/AppBar";
-import LoadingButton from "../common/button/LoadingButton";
-
-import "./index.css";
-
-const LoginPage: React.FC = () => {
-  const { t } = useTranslation();
-
-  const navigate = useNavigate();
-
-  const [username, setUsername] = React.useState<string>("");
-  const [usernameDirty, setUsernameDirty] = React.useState<boolean>(false);
-  const [password, setPassword] = React.useState<string>("");
-  const [passwordDirty, setPasswordDirty] = React.useState<boolean>(false);
-  const [rememberMe, setRememberMe] = React.useState<boolean>(true);
-  const [process, setProcess] = React.useState<boolean>(false);
-  const [error, setError] = React.useState<string | null>(null);
-
-  const user = useUser();
-
-  React.useEffect(() => {
-    if (user != null) {
-      const id = setTimeout(() => navigate("/"), 3000);
-      return () => {
-        clearTimeout(id);
-      };
-    }
-  }, [navigate, user]);
-
-  if (user != null) {
-    return (
-      <>
-        <AppBar />
-        <p>{t("login.alreadyLogin")}</p>
-      </>
-    );
-  }
-
-  const submit = (): void => {
-    if (username === "" || password === "") {
-      setUsernameDirty(true);
-      setPasswordDirty(true);
-      return;
-    }
-
-    setProcess(true);
-    userService
-      .login(
-        {
-          username: username,
-          password: password,
-        },
-        rememberMe
-      )
-      .then(
-        () => {
-          if (history.length === 0) {
-            navigate("/");
-          } else {
-            navigate(-1);
-          }
-        },
-        (e: Error) => {
-          setProcess(false);
-          setError(e.message);
-        }
-      );
-  };
-
-  const onEnterPressInPassword: React.KeyboardEventHandler = (e) => {
-    if (e.key === "Enter") {
-      submit();
-    }
-  };
-
-  return (
-    <div className="login-container container-fluid mt-2">
-      <h1 className="cru-text-center cru-color-primary">{t("welcome")}</h1>
-      <div className="cru-operation-dialog-group">
-        <label className="cru-operation-dialog-label" htmlFor="username">
-          {t("user.username")}
-        </label>
-        <input
-          id="username"
-          type="text"
-          disabled={process}
-          onChange={(e) => {
-            setUsername(e.target.value);
-            setUsernameDirty(true);
-          }}
-          value={username}
-        />
-        {usernameDirty && username === "" && (
-          <div className="cru-operation-dialog-error-text">
-            {t("login.emptyUsername")}
-          </div>
-        )}
-      </div>
-      <div className="cru-operation-dialog-group">
-        <label className="cru-operation-dialog-label" htmlFor="password">
-          {t("user.password")}
-        </label>
-        <input
-          id="password"
-          type="password"
-          disabled={process}
-          onChange={(e) => {
-            setPassword(e.target.value);
-            setPasswordDirty(true);
-          }}
-          value={password}
-          onKeyDown={onEnterPressInPassword}
-        />
-        {passwordDirty && password === "" && (
-          <div className="cru-operation-dialog-error-text">
-            {t("login.emptyPassword")}
-          </div>
-        )}
-      </div>
-      <div className="cru-operation-dialog-group">
-        <input
-          id="remember-me"
-          type="checkbox"
-          checked={rememberMe}
-          onChange={(e) => {
-            setRememberMe(e.currentTarget.checked);
-          }}
-        />
-        <label className="cru-operation-dialog-inline-label">
-          {t("user.rememberMe")}
-        </label>
-      </div>
-      {error ? <p className="cru-color-danger">{t(error)}</p> : null}
-      <div className="cru-text-end">
-        <LoadingButton
-          loading={process}
-          onClick={(e) => {
-            submit();
-            e.preventDefault();
-          }}
-          disabled={username === "" || password === "" ? true : undefined}
-        >
-          {t("user.login")}
-        </LoadingButton>
-      </div>
-      <Trans i18nKey="login.noAccount">
-        0<Link to="/register">1</Link>2
-      </Trans>
-    </div>
-  );
-};
-
-export default LoginPage;
-- 
cgit v1.2.3


From 538d6830a0022b49b99695095d85e567b0c86e71 Mon Sep 17 00:00:00 2001
From: crupest <crupest@outlook.com>
Date: Sun, 30 Jul 2023 23:47:53 +0800
Subject: ...

---
 FrontEnd/src/App.tsx                               |  14 +-
 FrontEnd/src/pages/home/index.css                  |   7 +
 FrontEnd/src/pages/home/index.tsx                  |   7 +
 FrontEnd/src/pages/loading/index.css               |   7 +
 FrontEnd/src/pages/loading/index.tsx               |  11 +
 FrontEnd/src/pages/timeline/CollapseButton.tsx     |  21 ++
 .../src/pages/timeline/ConnectionStatusBadge.css   |  36 +++
 .../src/pages/timeline/ConnectionStatusBadge.tsx   |  41 ++++
 FrontEnd/src/pages/timeline/MarkdownPostEdit.css   |  21 ++
 FrontEnd/src/pages/timeline/MarkdownPostEdit.tsx   | 215 +++++++++++++++++
 FrontEnd/src/pages/timeline/Timeline.css           | 244 +++++++++++++++++++
 FrontEnd/src/pages/timeline/Timeline.tsx           | 207 ++++++++++++++++
 FrontEnd/src/pages/timeline/TimelineCard.tsx       | 167 +++++++++++++
 FrontEnd/src/pages/timeline/TimelineDateLabel.tsx  |  19 ++
 .../src/pages/timeline/TimelineDeleteDialog.tsx    |  61 +++++
 FrontEnd/src/pages/timeline/TimelineEmptyItem.tsx  |  25 ++
 FrontEnd/src/pages/timeline/TimelineLine.tsx       |  51 ++++
 FrontEnd/src/pages/timeline/TimelineLoading.tsx    |  16 ++
 FrontEnd/src/pages/timeline/TimelineMember.css     |   8 +
 FrontEnd/src/pages/timeline/TimelineMember.tsx     | 202 ++++++++++++++++
 .../src/pages/timeline/TimelinePostContentView.tsx | 187 +++++++++++++++
 FrontEnd/src/pages/timeline/TimelinePostEdit.css   |  10 +
 FrontEnd/src/pages/timeline/TimelinePostEdit.tsx   | 267 +++++++++++++++++++++
 .../src/pages/timeline/TimelinePostEditCard.tsx    |  31 +++
 .../src/pages/timeline/TimelinePostEditNoLogin.tsx |  18 ++
 .../src/pages/timeline/TimelinePostListView.tsx    |  76 ++++++
 FrontEnd/src/pages/timeline/TimelinePostView.tsx   | 149 ++++++++++++
 .../timeline/TimelinePropertyChangeDialog.tsx      |  87 +++++++
 FrontEnd/src/pages/timeline/index.tsx              |  23 ++
 .../src/views/common/dialog/OperationDialog.tsx    |   6 +-
 FrontEnd/src/views/common/input/InputGroup.tsx     |  47 +++-
 31 files changed, 2257 insertions(+), 24 deletions(-)
 create mode 100644 FrontEnd/src/pages/home/index.css
 create mode 100644 FrontEnd/src/pages/home/index.tsx
 create mode 100644 FrontEnd/src/pages/loading/index.css
 create mode 100644 FrontEnd/src/pages/loading/index.tsx
 create mode 100644 FrontEnd/src/pages/timeline/CollapseButton.tsx
 create mode 100644 FrontEnd/src/pages/timeline/ConnectionStatusBadge.css
 create mode 100644 FrontEnd/src/pages/timeline/ConnectionStatusBadge.tsx
 create mode 100644 FrontEnd/src/pages/timeline/MarkdownPostEdit.css
 create mode 100644 FrontEnd/src/pages/timeline/MarkdownPostEdit.tsx
 create mode 100644 FrontEnd/src/pages/timeline/Timeline.css
 create mode 100644 FrontEnd/src/pages/timeline/Timeline.tsx
 create mode 100644 FrontEnd/src/pages/timeline/TimelineCard.tsx
 create mode 100644 FrontEnd/src/pages/timeline/TimelineDateLabel.tsx
 create mode 100644 FrontEnd/src/pages/timeline/TimelineDeleteDialog.tsx
 create mode 100644 FrontEnd/src/pages/timeline/TimelineEmptyItem.tsx
 create mode 100644 FrontEnd/src/pages/timeline/TimelineLine.tsx
 create mode 100644 FrontEnd/src/pages/timeline/TimelineLoading.tsx
 create mode 100644 FrontEnd/src/pages/timeline/TimelineMember.css
 create mode 100644 FrontEnd/src/pages/timeline/TimelineMember.tsx
 create mode 100644 FrontEnd/src/pages/timeline/TimelinePostContentView.tsx
 create mode 100644 FrontEnd/src/pages/timeline/TimelinePostEdit.css
 create mode 100644 FrontEnd/src/pages/timeline/TimelinePostEdit.tsx
 create mode 100644 FrontEnd/src/pages/timeline/TimelinePostEditCard.tsx
 create mode 100644 FrontEnd/src/pages/timeline/TimelinePostEditNoLogin.tsx
 create mode 100644 FrontEnd/src/pages/timeline/TimelinePostListView.tsx
 create mode 100644 FrontEnd/src/pages/timeline/TimelinePostView.tsx
 create mode 100644 FrontEnd/src/pages/timeline/TimelinePropertyChangeDialog.tsx
 create mode 100644 FrontEnd/src/pages/timeline/index.tsx

(limited to 'FrontEnd/src/App.tsx')

diff --git a/FrontEnd/src/App.tsx b/FrontEnd/src/App.tsx
index f638f5e8..ca3e4d38 100644
--- a/FrontEnd/src/App.tsx
+++ b/FrontEnd/src/App.tsx
@@ -1,27 +1,26 @@
-import * as React from "react";
+import { Suspense } from "react";
 import { BrowserRouter, Route, Routes } from "react-router-dom";
 
 import AppBar from "./views/common/AppBar";
 import NotFoundPage from "./pages/404";
-import LoadingPage from "./views/common/LoadingPage";
+import HomePage from "./pages/home";
 import AboutPage from "./pages/about";
 import SettingPage from "./pages/setting";
-import Center from "./views/center";
 import LoginPage from "./pages/login";
 import RegisterPage from "./pages/register";
-import TimelinePage from "./views/timeline";
+import TimelinePage from "./pages/timeline";
+import LoadingPage from "./pages/loading";
 import Search from "./views/search";
 import Admin from "./views/admin";
 import AlertHost from "./views/common/alert/AlertHost";
 
 export default function App() {
   return (
-    <React.Suspense fallback={<LoadingPage />}>
+    <Suspense fallback={<LoadingPage />}>
       <BrowserRouter>
         <AppBar />
         <div style={{ height: 56 }} />
         <Routes>
-          <Route path="center" element={<Center />} />
           <Route path="login" element={<LoginPage />} />
           <Route path="register" element={<RegisterPage />} />
           <Route path="settings" element={<SettingPage />} />
@@ -30,10 +29,11 @@ export default function App() {
           <Route path="admin/*" element={<Admin />} />
           <Route path=":owner" element={<TimelinePage />} />
           <Route path=":owner/:timeline" element={<TimelinePage />} />
+          <Route path="" element={<HomePage />} />
           <Route path="*" element={<NotFoundPage />} />
         </Routes>
         <AlertHost />
       </BrowserRouter>
-    </React.Suspense>
+    </Suspense>
   );
 }
diff --git a/FrontEnd/src/pages/home/index.css b/FrontEnd/src/pages/home/index.css
new file mode 100644
index 00000000..bc72a182
--- /dev/null
+++ b/FrontEnd/src/pages/home/index.css
@@ -0,0 +1,7 @@
+.home-page {
+  width: 100%;
+  text-align: center;
+  padding-top: 1em;
+  font-size: 2em;
+  color: var(--cru-primary-color);
+}
\ No newline at end of file
diff --git a/FrontEnd/src/pages/home/index.tsx b/FrontEnd/src/pages/home/index.tsx
new file mode 100644
index 00000000..76a3d18c
--- /dev/null
+++ b/FrontEnd/src/pages/home/index.tsx
@@ -0,0 +1,7 @@
+import "./index.css";
+
+export default function HomePage() {
+  return (
+    <div className="home-page">Be patient! I&apos;m working on this...</div>
+  );
+}
diff --git a/FrontEnd/src/pages/loading/index.css b/FrontEnd/src/pages/loading/index.css
new file mode 100644
index 00000000..08e43c22
--- /dev/null
+++ b/FrontEnd/src/pages/loading/index.css
@@ -0,0 +1,7 @@
+.loading-page {
+  width: 100%;
+  text-align: center;
+  padding-top: 1em;
+  font-size: 2em;
+  color: var(--cru-primary-color);
+}
\ No newline at end of file
diff --git a/FrontEnd/src/pages/loading/index.tsx b/FrontEnd/src/pages/loading/index.tsx
new file mode 100644
index 00000000..e4c8edab
--- /dev/null
+++ b/FrontEnd/src/pages/loading/index.tsx
@@ -0,0 +1,11 @@
+import Spinner from "@/views/common/Spinner";
+
+import "./index.css";
+
+export default function LoadingPage() {
+  return (
+    <div className="loading-page">
+      <Spinner />
+    </div>
+  );
+}
diff --git a/FrontEnd/src/pages/timeline/CollapseButton.tsx b/FrontEnd/src/pages/timeline/CollapseButton.tsx
new file mode 100644
index 00000000..8270e160
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/CollapseButton.tsx
@@ -0,0 +1,21 @@
+import * as React from "react";
+
+import IconButton from "@/views/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/pages/timeline/ConnectionStatusBadge.css b/FrontEnd/src/pages/timeline/ConnectionStatusBadge.css
new file mode 100644
index 00000000..7fe83b9b
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/ConnectionStatusBadge.css
@@ -0,0 +1,36 @@
+.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/pages/timeline/ConnectionStatusBadge.tsx b/FrontEnd/src/pages/timeline/ConnectionStatusBadge.tsx
new file mode 100644
index 00000000..2b820454
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/ConnectionStatusBadge.tsx
@@ -0,0 +1,41 @@
+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/pages/timeline/MarkdownPostEdit.css b/FrontEnd/src/pages/timeline/MarkdownPostEdit.css
new file mode 100644
index 00000000..e36be992
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/MarkdownPostEdit.css
@@ -0,0 +1,21 @@
+.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/pages/timeline/MarkdownPostEdit.tsx b/FrontEnd/src/pages/timeline/MarkdownPostEdit.tsx
new file mode 100644
index 00000000..9c497108
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/MarkdownPostEdit.tsx
@@ -0,0 +1,215 @@
+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 "@/views/common/button/FlatButton";
+import TabPages from "@/views/common/tab/TabPages";
+import ConfirmDialog from "@/views/common/dialog/ConfirmDialog";
+import Spinner from "@/views/common/Spinner";
+import IconButton from "@/views/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/pages/timeline/Timeline.css b/FrontEnd/src/pages/timeline/Timeline.css
new file mode 100644
index 00000000..4dd4fdcc
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/Timeline.css
@@ -0,0 +1,244 @@
+.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/pages/timeline/Timeline.tsx b/FrontEnd/src/pages/timeline/Timeline.tsx
new file mode 100644
index 00000000..3a7fbd00
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/Timeline.tsx
@@ -0,0 +1,207 @@
+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/pages/timeline/TimelineCard.tsx b/FrontEnd/src/pages/timeline/TimelineCard.tsx
new file mode 100644
index 00000000..8ce133c0
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelineCard.tsx
@@ -0,0 +1,167 @@
+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 "@/views/common/user/UserAvatar";
+import PopupMenu from "@/views/common/menu/PopupMenu";
+import FullPageDialog from "@/views/common/dialog/FullPageDialog";
+import Card from "@/views/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 "@/views/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/pages/timeline/TimelineDateLabel.tsx b/FrontEnd/src/pages/timeline/TimelineDateLabel.tsx
new file mode 100644
index 00000000..5f4ac706
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelineDateLabel.tsx
@@ -0,0 +1,19 @@
+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/pages/timeline/TimelineDeleteDialog.tsx b/FrontEnd/src/pages/timeline/TimelineDeleteDialog.tsx
new file mode 100644
index 00000000..d5b22aee
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelineDeleteDialog.tsx
@@ -0,0 +1,61 @@
+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 "@/views/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"
+      color="danger"
+      inputPrompt={() => (
+        <Trans
+          i18nKey="timeline.deleteDialog.inputPrompt"
+          values={{ name: timeline.nameV2 }}
+        >
+          0<code className="mx-2">1</code>2
+        </Trans>
+      )}
+      inputs={{
+        inputs: [
+          {
+            key: "name",
+            type: "text",
+            label: "",
+          },
+        ],
+        validator: ({ name }) => {
+          if (name !== timeline.nameV2) {
+            return { name: "timeline.deleteDialog.notMatch" };
+          }
+        },
+      }}
+      onProcess={() => {
+        return getHttpTimelineClient().deleteTimeline(
+          timeline.owner.username,
+          timeline.nameV2,
+        );
+      }}
+      onSuccessAndClose={() => {
+        navigate("/", { replace: true });
+      }}
+    />
+  );
+};
+
+export default TimelineDeleteDialog;
diff --git a/FrontEnd/src/pages/timeline/TimelineEmptyItem.tsx b/FrontEnd/src/pages/timeline/TimelineEmptyItem.tsx
new file mode 100644
index 00000000..5e0728d4
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelineEmptyItem.tsx
@@ -0,0 +1,25 @@
+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/pages/timeline/TimelineLine.tsx b/FrontEnd/src/pages/timeline/TimelineLine.tsx
new file mode 100644
index 00000000..4a87e6e0
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelineLine.tsx
@@ -0,0 +1,51 @@
+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/pages/timeline/TimelineLoading.tsx b/FrontEnd/src/pages/timeline/TimelineLoading.tsx
new file mode 100644
index 00000000..f876cba9
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelineLoading.tsx
@@ -0,0 +1,16 @@
+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/pages/timeline/TimelineMember.css b/FrontEnd/src/pages/timeline/TimelineMember.css
new file mode 100644
index 00000000..adb78764
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelineMember.css
@@ -0,0 +1,8 @@
+.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/pages/timeline/TimelineMember.tsx b/FrontEnd/src/pages/timeline/TimelineMember.tsx
new file mode 100644
index 00000000..6c5d29ba
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelineMember.tsx
@@ -0,0 +1,202 @@
+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 "@/views/common/SearchInput";
+import UserAvatar from "@/views/common/user/UserAvatar";
+import Button from "@/views/common/button/Button";
+import Dialog from "@/views/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/pages/timeline/TimelinePostContentView.tsx b/FrontEnd/src/pages/timeline/TimelinePostContentView.tsx
new file mode 100644
index 00000000..41080e10
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelinePostContentView.tsx
@@ -0,0 +1,187 @@
+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 "@/views/common/Skeleton";
+import LoadFailReload from "@/views/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/pages/timeline/TimelinePostEdit.css b/FrontEnd/src/pages/timeline/TimelinePostEdit.css
new file mode 100644
index 00000000..9b7629e2
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelinePostEdit.css
@@ -0,0 +1,10 @@
+.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/pages/timeline/TimelinePostEdit.tsx b/FrontEnd/src/pages/timeline/TimelinePostEdit.tsx
new file mode 100644
index 00000000..c1fa0dd9
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelinePostEdit.tsx
@@ -0,0 +1,267 @@
+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 "@/views/common/BlobImage";
+import LoadingButton from "@/views/common/button/LoadingButton";
+import PopupMenu from "@/views/common/menu/PopupMenu";
+import MarkdownPostEdit from "./MarkdownPostEdit";
+import TimelinePostEditCard from "./TimelinePostEditCard";
+import IconButton from "@/views/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/pages/timeline/TimelinePostEditCard.tsx b/FrontEnd/src/pages/timeline/TimelinePostEditCard.tsx
new file mode 100644
index 00000000..fdd53983
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelinePostEditCard.tsx
@@ -0,0 +1,31 @@
+import * as React from "react";
+import classnames from "classnames";
+
+import Card from "@/views/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/pages/timeline/TimelinePostEditNoLogin.tsx b/FrontEnd/src/pages/timeline/TimelinePostEditNoLogin.tsx
new file mode 100644
index 00000000..1ef0a287
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelinePostEditNoLogin.tsx
@@ -0,0 +1,18 @@
+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/pages/timeline/TimelinePostListView.tsx b/FrontEnd/src/pages/timeline/TimelinePostListView.tsx
new file mode 100644
index 00000000..f878b004
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelinePostListView.tsx
@@ -0,0 +1,76 @@
+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/pages/timeline/TimelinePostView.tsx b/FrontEnd/src/pages/timeline/TimelinePostView.tsx
new file mode 100644
index 00000000..f7aec169
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelinePostView.tsx
@@ -0,0 +1,149 @@
+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 "@/views/common/user/UserAvatar";
+import Card from "@/views/common/Card";
+import FlatButton from "@/views/common/button/FlatButton";
+import ConfirmDialog from "@/views/common/dialog/ConfirmDialog";
+import TimelineLine from "./TimelineLine";
+import TimelinePostContentView from "./TimelinePostContentView";
+import IconButton from "@/views/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
+        containerRef={cardRef}
+        className="timeline-item-card enter-animation"
+        style={cardStyle}
+      >
+        {post.editable ? (
+          <IconButton
+            icon="chevron-down"
+            color="primary"
+            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",
+              });
+            });
+        }}
+      />
+    </div>
+  );
+};
+
+export default TimelinePostView;
diff --git a/FrontEnd/src/pages/timeline/TimelinePropertyChangeDialog.tsx b/FrontEnd/src/pages/timeline/TimelinePropertyChangeDialog.tsx
new file mode 100644
index 00000000..e26df3eb
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelinePropertyChangeDialog.tsx
@@ -0,0 +1,87 @@
+import * as React from "react";
+
+import {
+  getHttpTimelineClient,
+  HttpTimelineInfo,
+  HttpTimelinePatchRequest,
+  kTimelineVisibilities,
+  TimelineVisibility,
+} from "@/http/timeline";
+
+import OperationDialog from "@/views/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"}
+      inputs={{
+        scheme: {
+          inputs: [
+            {
+              key: "title",
+              type: "text",
+              label: "timeline.dialogChangeProperty.titleField",
+            },
+            {
+              key: "visibility",
+              type: "select",
+              label: "timeline.dialogChangeProperty.visibility",
+              options: kTimelineVisibilities.map((v) => ({
+                label: labelMap[v],
+                value: v,
+              })),
+            },
+            {
+              key: "description",
+              type: "text",
+              label: "timeline.dialogChangeProperty.description",
+            },
+          ],
+        },
+        dataInit: {
+          values: {
+            title: timeline.title,
+            visibility: timeline.visibility,
+            description: timeline.description,
+          },
+        },
+      }}
+      open={props.open}
+      onClose={props.close}
+      onProcess={({ title, visibility, description }) => {
+        const req: HttpTimelinePatchRequest = {};
+        if (title !== timeline.title) {
+          req.title = title as string;
+        }
+        if (visibility !== timeline.visibility) {
+          req.visibility = visibility as TimelineVisibility;
+        }
+        if (description !== timeline.description) {
+          req.description = description as string;
+        }
+        return getHttpTimelineClient()
+          .patchTimeline(timeline.owner.username, timeline.nameV2, req)
+          .then(onChange);
+      }}
+    />
+  );
+};
+
+export default TimelinePropertyChangeDialog;
diff --git a/FrontEnd/src/pages/timeline/index.tsx b/FrontEnd/src/pages/timeline/index.tsx
new file mode 100644
index 00000000..1dffdcc1
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/index.tsx
@@ -0,0 +1,23 @@
+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;
diff --git a/FrontEnd/src/views/common/dialog/OperationDialog.tsx b/FrontEnd/src/views/common/dialog/OperationDialog.tsx
index da1ff0e0..de92e541 100644
--- a/FrontEnd/src/views/common/dialog/OperationDialog.tsx
+++ b/FrontEnd/src/views/common/dialog/OperationDialog.tsx
@@ -43,7 +43,7 @@ export interface OperationDialogProps<TData> {
   color?: ThemeColor;
   inputColor?: ThemeColor;
   title: Text;
-  inputPrompt?: Text;
+  inputPrompt?: () => ReactNode;
   successPrompt?: (data: TData) => ReactNode;
   failurePrompt?: (error: unknown) => ReactNode;
 
@@ -68,8 +68,6 @@ function OperationDialog<TData>(props: OperationDialogProps<TData>) {
     onSuccessAndClose,
   } = props;
 
-  const c = useC();
-
   type Step =
     | { type: "input" }
     | { type: "process" }
@@ -130,7 +128,7 @@ function OperationDialog<TData>(props: OperationDialogProps<TData>) {
 
     body = (
       <div>
-        <OperationDialogPrompt customMessage={c(inputPrompt)} />
+        <OperationDialogPrompt customMessage={inputPrompt?.()} />
         <InputGroup
           containerClassName="cru-operation-dialog-input-group"
           color={inputColor ?? "primary"}
diff --git a/FrontEnd/src/views/common/input/InputGroup.tsx b/FrontEnd/src/views/common/input/InputGroup.tsx
index ee89b05c..5714411c 100644
--- a/FrontEnd/src/views/common/input/InputGroup.tsx
+++ b/FrontEnd/src/views/common/input/InputGroup.tsx
@@ -70,6 +70,13 @@ export type InputErrorDict = Record<string, Text>;
 export type InputDisabledDict = Record<string, boolean>;
 export type InputDirtyDict = Record<string, boolean>;
 
+export type GeneralInputErrorDict =
+  | {
+      [key: string]: Text | null | undefined;
+    }
+  | null
+  | undefined;
+
 type MakeInputInfo<I extends Input> = Omit<I, "value" | "error" | "disabled">;
 
 export type InputInfo = {
@@ -79,7 +86,7 @@ export type InputInfo = {
 export type Validator = (
   values: InputValueDict,
   inputs: InputInfo[],
-) => InputErrorDict;
+) => GeneralInputErrorDict;
 
 export type InputScheme = {
   inputs: InputInfo[];
@@ -98,7 +105,12 @@ export type State = {
   data: InputData;
 };
 
-export type DataInitialization = Partial<InputData>;
+export type DataInitialization = {
+  values?: InputValueDict;
+  errors?: GeneralInputErrorDict;
+  disabled?: InputDisabledDict;
+  dirties?: InputDirtyDict;
+};
 
 export type Initialization = {
   scheme: InputScheme;
@@ -118,14 +130,14 @@ export interface InputGroupProps {
   onChange: (index: number, value: Input["value"]) => void;
 }
 
-function cleanObject<V>(o: Record<string, V>): Record<string, V> {
+function cleanObject<V>(o: Record<string, V>): Record<string, NonNullable<V>> {
   const result = { ...o };
   for (const key of Object.keys(result)) {
     if (result[key] == null) {
       delete result[key];
     }
   }
-  return result;
+  return result as never;
 }
 
 export type ConfirmResult =
@@ -138,6 +150,14 @@ export type ConfirmResult =
       errors: InputErrorDict;
     };
 
+function validate(
+  validator: Validator | null | undefined,
+  values: InputValueDict,
+  inputs: InputInfo[],
+): InputErrorDict {
+  return cleanObject(validator?.(values, inputs) ?? {});
+}
+
 export function useInputs(options: { init: Initializer }): {
   inputGroupProps: InputGroupProps;
   hasError: boolean;
@@ -182,18 +202,22 @@ export function useInputs(options: { init: Initializer }): {
       };
 
       checkKeys(dataInit?.values);
-      checkKeys(dataInit?.errors);
+      checkKeys(dataInit?.errors ?? {});
       checkKeys(dataInit?.disabled);
       checkKeys(dataInit?.dirties);
     }
 
-    function clean<V>(dict: Record<string, V> | undefined): Record<string, V> {
+    function clean<V>(
+      dict: Record<string, V> | null | undefined,
+    ): Record<string, NonNullable<V>> {
       return dict != null ? cleanObject(dict) : {};
     }
 
     const values: InputValueDict = {};
     const disabled: InputDisabledDict = clean(dataInit?.disabled);
     const dirties: InputDirtyDict = clean(dataInit?.dirties);
+    const isErrorSet = dataInit?.errors != null;
+    let errors: InputErrorDict = clean(dataInit?.errors);
 
     for (let i = 0; i < inputs.length; i++) {
       const input = inputs[i];
@@ -202,17 +226,14 @@ export function useInputs(options: { init: Initializer }): {
       values[key] = initializeValue(input, dataInit?.values?.[key]);
     }
 
-    let errors = dataInit?.errors;
-
-    if (errors != null) {
+    if (isErrorSet) {
       if (process.env.NODE_ENV === "development") {
         console.log(
           "You explicitly set errors (not undefined) in initializer, so validator won't run.",
         );
       }
-      errors = cleanObject(errors);
     } else {
-      errors = validator?.(values, inputs) ?? {};
+      errors = validate(validator, values, inputs);
     }
 
     return {
@@ -272,7 +293,7 @@ export function useInputs(options: { init: Initializer }): {
         const { key } = input;
         const newValues = { ...data.values, [key]: value };
         const newDirties = { ...data.dirties, [key]: true };
-        const newErrors = validator?.(newValues, scheme.inputs) ?? {};
+        const newErrors = validate(validator, newValues, scheme.inputs);
         setState({
           scheme,
           data: {
@@ -288,7 +309,7 @@ export function useInputs(options: { init: Initializer }): {
     hasErrorAndDirty: hasError && hasDirty,
     confirm() {
       const newDirties = createAllDirties();
-      const newErrors = validator?.(data.values, scheme.inputs) ?? {};
+      const newErrors = validate(validator, data.values, scheme.inputs);
 
       setState({
         scheme,
-- 
cgit v1.2.3


From 645a88e7e35d15cec6106709c42b071bec045e0d Mon Sep 17 00:00:00 2001
From: crupest <crupest@outlook.com>
Date: Wed, 2 Aug 2023 02:52:07 +0800
Subject: ...

---
 BackEnd/Timeline.Tests/Timeline.Tests.csproj       |  20 +-
 BackEnd/Timeline.Tests/packages.lock.json          | 307 ++++++++--------
 BackEnd/Timeline/Timeline.csproj                   |  18 +-
 BackEnd/Timeline/packages.lock.json                | 171 ++++-----
 FrontEnd/.eslintignore                             |   1 +
 FrontEnd/src/App.tsx                               |   4 -
 FrontEnd/src/migrating/admin/Admin.tsx             |  27 ++
 FrontEnd/src/migrating/admin/AdminNav.tsx          |  29 ++
 FrontEnd/src/migrating/admin/MoreAdmin.tsx         |   7 +
 FrontEnd/src/migrating/admin/UserAdmin.tsx         | 301 ++++++++++++++++
 FrontEnd/src/migrating/admin/index.css             |  33 ++
 FrontEnd/src/migrating/admin/index.tsx             |   7 +
 FrontEnd/src/migrating/center/CenterBoards.tsx     | 131 +++++++
 FrontEnd/src/migrating/center/TimelineBoard.tsx    | 390 +++++++++++++++++++++
 .../src/migrating/center/TimelineCreateDialog.tsx  |  57 +++
 FrontEnd/src/migrating/center/index.css            |  43 +++
 FrontEnd/src/migrating/center/index.tsx            |  60 ++++
 FrontEnd/src/services/alert.ts                     |   4 +-
 FrontEnd/src/views/admin/Admin.tsx                 |  27 --
 FrontEnd/src/views/admin/AdminNav.tsx              |  29 --
 FrontEnd/src/views/admin/MoreAdmin.tsx             |   7 -
 FrontEnd/src/views/admin/UserAdmin.tsx             | 304 ----------------
 FrontEnd/src/views/admin/index.css                 |  33 --
 FrontEnd/src/views/admin/index.tsx                 |   7 -
 FrontEnd/src/views/center/CenterBoards.tsx         | 131 -------
 FrontEnd/src/views/center/TimelineBoard.tsx        | 390 ---------------------
 FrontEnd/src/views/center/TimelineCreateDialog.tsx |  57 ---
 FrontEnd/src/views/center/index.css                |  43 ---
 FrontEnd/src/views/center/index.tsx                |  60 ----
 FrontEnd/src/views/common/button/LoadingButton.css |   2 +-
 FrontEnd/src/views/search/index.css                |  15 -
 FrontEnd/src/views/search/index.tsx                | 131 -------
 FrontEnd/tsconfig.json                             |   3 +
 33 files changed, 1336 insertions(+), 1513 deletions(-)
 create mode 100644 FrontEnd/src/migrating/admin/Admin.tsx
 create mode 100644 FrontEnd/src/migrating/admin/AdminNav.tsx
 create mode 100644 FrontEnd/src/migrating/admin/MoreAdmin.tsx
 create mode 100644 FrontEnd/src/migrating/admin/UserAdmin.tsx
 create mode 100644 FrontEnd/src/migrating/admin/index.css
 create mode 100644 FrontEnd/src/migrating/admin/index.tsx
 create mode 100644 FrontEnd/src/migrating/center/CenterBoards.tsx
 create mode 100644 FrontEnd/src/migrating/center/TimelineBoard.tsx
 create mode 100644 FrontEnd/src/migrating/center/TimelineCreateDialog.tsx
 create mode 100644 FrontEnd/src/migrating/center/index.css
 create mode 100644 FrontEnd/src/migrating/center/index.tsx
 delete mode 100644 FrontEnd/src/views/admin/Admin.tsx
 delete mode 100644 FrontEnd/src/views/admin/AdminNav.tsx
 delete mode 100644 FrontEnd/src/views/admin/MoreAdmin.tsx
 delete mode 100644 FrontEnd/src/views/admin/UserAdmin.tsx
 delete mode 100644 FrontEnd/src/views/admin/index.css
 delete mode 100644 FrontEnd/src/views/admin/index.tsx
 delete mode 100644 FrontEnd/src/views/center/CenterBoards.tsx
 delete mode 100644 FrontEnd/src/views/center/TimelineBoard.tsx
 delete mode 100644 FrontEnd/src/views/center/TimelineCreateDialog.tsx
 delete mode 100644 FrontEnd/src/views/center/index.css
 delete mode 100644 FrontEnd/src/views/center/index.tsx
 delete mode 100644 FrontEnd/src/views/search/index.css
 delete mode 100644 FrontEnd/src/views/search/index.tsx

(limited to 'FrontEnd/src/App.tsx')

diff --git a/BackEnd/Timeline.Tests/Timeline.Tests.csproj b/BackEnd/Timeline.Tests/Timeline.Tests.csproj
index 57694c2b..3983bc0b 100644
--- a/BackEnd/Timeline.Tests/Timeline.Tests.csproj
+++ b/BackEnd/Timeline.Tests/Timeline.Tests.csproj
@@ -8,15 +8,21 @@
     </PropertyGroup>
 
     <ItemGroup>
-        <PackageReference Include="coverlet.collector" Version="3.2.0" />
-        <PackageReference Include="FluentAssertions" Version="6.10.0" />
+        <PackageReference Include="coverlet.collector" Version="6.0.0">
+          <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+          <PrivateAssets>all</PrivateAssets>
+        </PackageReference>
+        <PackageReference Include="FluentAssertions" Version="6.11.0" />
         <PackageReference Include="MartinCostello.Logging.XUnit" Version="0.3.0" />
-        <PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="7.0.3" />
-        <PackageReference Include="Microsoft.AspNetCore.TestHost" Version="7.0.3" />
-        <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.5.0" />
+        <PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="7.0.9" />
+        <PackageReference Include="Microsoft.AspNetCore.TestHost" Version="7.0.9" />
+        <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.6.3" />
         <PackageReference Include="Moq" Version="4.18.4" />
-        <PackageReference Include="xunit" Version="2.4.2" />
-        <PackageReference Include="xunit.runner.visualstudio" Version="2.4.5" />
+        <PackageReference Include="xunit" Version="2.5.0" />
+        <PackageReference Include="xunit.runner.visualstudio" Version="2.5.0">
+          <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+          <PrivateAssets>all</PrivateAssets>
+        </PackageReference>
     </ItemGroup>
 
     <ItemGroup>
diff --git a/BackEnd/Timeline.Tests/packages.lock.json b/BackEnd/Timeline.Tests/packages.lock.json
index cc7108a4..cb747b24 100644
--- a/BackEnd/Timeline.Tests/packages.lock.json
+++ b/BackEnd/Timeline.Tests/packages.lock.json
@@ -4,15 +4,15 @@
     "net7.0": {
       "coverlet.collector": {
         "type": "Direct",
-        "requested": "[3.2.0, )",
-        "resolved": "3.2.0",
-        "contentHash": "xjY8xBigSeWIYs4I7DgUHqSNoGqnHi7Fv7/7RZD02rvZyG3hlsjnQKiVKVWKgr9kRKgmV+dEfu8KScvysiC0Wg=="
+        "requested": "[6.0.0, )",
+        "resolved": "6.0.0",
+        "contentHash": "tW3lsNS+dAEII6YGUX/VMoJjBS1QvsxqJeqLaJXub08y1FSjasFPtQ4UBUsudE9PNrzLjooClMsPtY2cZLdXpQ=="
       },
       "FluentAssertions": {
         "type": "Direct",
-        "requested": "[6.10.0, )",
-        "resolved": "6.10.0",
-        "contentHash": "Da3YsiRDnOHKBfxutjnupL1rOX0K/jnG6crn5AgwukeqZ/yi+HNCOFshic01ke0ztZFWzpfQMXH8fO9aAbG0Gw==",
+        "requested": "[6.11.0, )",
+        "resolved": "6.11.0",
+        "contentHash": "aBaagwdNtVKkug1F3imGXUlmoBd8ZUZX8oQ5niThaJhF79SpESe1Gzq7OFuZkQdKD5Pa4Mone+jrbas873AT4g==",
         "dependencies": {
           "System.Configuration.ConfigurationManager": "4.4.0"
         }
@@ -30,31 +30,31 @@
       },
       "Microsoft.AspNetCore.SignalR.Client": {
         "type": "Direct",
-        "requested": "[7.0.3, )",
-        "resolved": "7.0.3",
-        "contentHash": "kyxW20UJwTf1VZuiK8jWhz9tx1NhfzCG5KoOlHDnpr0dy2utlHil9xgdlsAC5V46K1Dser3DZDbCn0Qq8x4K1w==",
+        "requested": "[7.0.9, )",
+        "resolved": "7.0.9",
+        "contentHash": "tXSCmD/oZimbnzOfVSz8CGrKy/OLDvWJ87Hp1Yg8IkG/8ek3532Hk8Ujf/gNjZy1jRLT2s4jcQS7iwGguhvi3g==",
         "dependencies": {
-          "Microsoft.AspNetCore.Http.Connections.Client": "7.0.3",
-          "Microsoft.AspNetCore.SignalR.Client.Core": "7.0.3"
+          "Microsoft.AspNetCore.Http.Connections.Client": "7.0.9",
+          "Microsoft.AspNetCore.SignalR.Client.Core": "7.0.9"
         }
       },
       "Microsoft.AspNetCore.TestHost": {
         "type": "Direct",
-        "requested": "[7.0.3, )",
-        "resolved": "7.0.3",
-        "contentHash": "wS+gBqK8O6ASpGjEoRdxKR4qb/Hqt+RumM1bMfKxkjYpsBl4UMh8ClmXzU42omn3sWCpPqxZIBSIO3F4zEYPzQ==",
+        "requested": "[7.0.9, )",
+        "resolved": "7.0.9",
+        "contentHash": "QHbCXxCpVEKFyZ80JspVjIkjy6LsavJXGUc1qHN3bJk4SipVMTM8rq/lhYM8Gu1xJJX8pH/AqguFa391Nx9RTw==",
         "dependencies": {
           "System.IO.Pipelines": "7.0.0"
         }
       },
       "Microsoft.NET.Test.Sdk": {
         "type": "Direct",
-        "requested": "[17.5.0, )",
-        "resolved": "17.5.0",
-        "contentHash": "IJ4eSPcsRbwbAZehh1M9KgejSy0u3d0wAdkJytfCh67zOaCl5U3ltruUEe15MqirdRqGmm/ngbjeaVeGapSZxg==",
+        "requested": "[17.6.3, )",
+        "resolved": "17.6.3",
+        "contentHash": "MglaNTl646dC2xpHKotSk1xscmHO5uV3x3NK057IUA9BM3Wgl16WMEb9ptGczk518JfLd1+Th5OAYwnoWgHQQQ==",
         "dependencies": {
-          "Microsoft.CodeCoverage": "17.5.0",
-          "Microsoft.TestPlatform.TestHost": "17.5.0"
+          "Microsoft.CodeCoverage": "17.6.3",
+          "Microsoft.TestPlatform.TestHost": "17.6.3"
         }
       },
       "Moq": {
@@ -68,20 +68,20 @@
       },
       "xunit": {
         "type": "Direct",
-        "requested": "[2.4.2, )",
-        "resolved": "2.4.2",
-        "contentHash": "6Mj73Ont3zj2CJuoykVJfE0ZmRwn7C+pTuRP8c4bnaaTFjwNG6tGe0prJ1yIbMe9AHrpDys63ctWacSsFJWK/w==",
+        "requested": "[2.5.0, )",
+        "resolved": "2.5.0",
+        "contentHash": "f2V5wuAdoaq0mRTt9UBmPbVex9HcwFYn+y7WaKUz5Xpakcrv7lhtQWBJUWNY4N3Z+o+atDBLyAALM1QWx04C6Q==",
         "dependencies": {
-          "xunit.analyzers": "1.0.0",
-          "xunit.assert": "2.4.2",
-          "xunit.core": "[2.4.2]"
+          "xunit.analyzers": "1.2.0",
+          "xunit.assert": "2.5.0",
+          "xunit.core": "[2.5.0]"
         }
       },
       "xunit.runner.visualstudio": {
         "type": "Direct",
-        "requested": "[2.4.5, )",
-        "resolved": "2.4.5",
-        "contentHash": "OwHamvBdUKgqsXfBzWiCW/O98BTx81UKzx2bieIOQI7CZFE5NEQZGi8PBQGIKawDW96xeRffiNf20SjfC0x9hw=="
+        "requested": "[2.5.0, )",
+        "resolved": "2.5.0",
+        "contentHash": "+Gp9vuC2431yPyKB15YrOTxCuEAErBQUTIs6CquumX1F073UaPHGW0VE/XVJLMh9W4sXdz3TBkcHdFWZrRn2Hw=="
       },
       "AutoMapper": {
         "type": "Transitive",
@@ -93,10 +93,10 @@
       },
       "AutoMapper.Extensions.Microsoft.DependencyInjection": {
         "type": "Transitive",
-        "resolved": "12.0.0",
-        "contentHash": "XCJ4E3oKrbRl1qY9Mr+7uyC0xZj1+bqQjmQRWTiTKiVuuXTny+7YFWHi20tPjwkMukLbicN6yGlDy5PZ4wyi1w==",
+        "resolved": "12.0.1",
+        "contentHash": "+g/K+Vpe3gGMKGzjslMOdqNlkikScDjWfVvmWTayrDHaG/n2pPmFBMa+jKX1r/h6BDGFdkyRjAuhFE3ykW+r1g==",
         "dependencies": {
-          "AutoMapper": "12.0.0",
+          "AutoMapper": "[12.0.1]",
           "Microsoft.Extensions.Options": "6.0.0"
         }
       },
@@ -110,43 +110,43 @@
       },
       "Markdig": {
         "type": "Transitive",
-        "resolved": "0.30.4",
-        "contentHash": "9LL7Mzubwf6QmOTZIqf+vERiinqPA1H+irBA38POhr93W/it00AvFWaCtF80+8VJTooFmcAJyZT2f60t1f88eQ=="
+        "resolved": "0.31.0",
+        "contentHash": "CEdZ3KyFfz5IPMrofhCOSrc4O2KNvSXSMXt3fv0W5AFCsk6a/JhKZuTBxGSadNt8n4zyyd/xXfoUMvr4ZbZ4Fw=="
       },
       "Microsoft.AspNetCore.Connections.Abstractions": {
         "type": "Transitive",
-        "resolved": "7.0.3",
-        "contentHash": "uR20YarXQQfazLUtfn2KBNRgROtsvsNRRku9+s0o1MpkVn6aWhJ0udzMdTUZ3hrXo/VP3UoRbi9f4OYlJF0DLA==",
+        "resolved": "7.0.9",
+        "contentHash": "T/A7MkuL0g4r/oNuASR2gIukWoG2360/tuS9RoiU1dOwTlMYthHJaK8NEpswMihcImCDryiHp5dJCPTZHIs9TQ==",
         "dependencies": {
-          "Microsoft.Extensions.Features": "7.0.3",
+          "Microsoft.Extensions.Features": "7.0.9",
           "System.IO.Pipelines": "7.0.0"
         }
       },
       "Microsoft.AspNetCore.Http.Connections.Client": {
         "type": "Transitive",
-        "resolved": "7.0.3",
-        "contentHash": "t2+AReXjZeL47pq50gg/gfmX3u0jK++LL8pg0IoyG9IjhlFKl/okZcvXWp/mMPKRyMmpKJvVb++tQLMyIOEYlw==",
+        "resolved": "7.0.9",
+        "contentHash": "41NrQVGv+M5KpujoaUwPs8DGaQrkvhJvaotuvdyvsuTPlcpzqBy+bmQs2eUXEpOfFxOWL9uqZcwLpoOkCOn3/A==",
         "dependencies": {
-          "Microsoft.AspNetCore.Http.Connections.Common": "7.0.3",
-          "Microsoft.Extensions.Logging.Abstractions": "7.0.0",
+          "Microsoft.AspNetCore.Http.Connections.Common": "7.0.9",
+          "Microsoft.Extensions.Logging.Abstractions": "7.0.1",
           "Microsoft.Extensions.Options": "7.0.1"
         }
       },
       "Microsoft.AspNetCore.Http.Connections.Common": {
         "type": "Transitive",
-        "resolved": "7.0.3",
-        "contentHash": "egbJZ83GxjFt6WWD9Oab6lLf4eRHyxA0mRzBX/2GIX4nXoLt0vvIk9LciCRhwWHjFkVj8zOBfjEQ58RXfKOs4Q==",
+        "resolved": "7.0.9",
+        "contentHash": "GnhaII0AWIAuUTJ3TT02yUOAPlVP4p7zxi40ysQYSLUiD6dEODJc9d3ugFgM8EMnrgYiKw6fbLyn4dixls8CCg==",
         "dependencies": {
-          "Microsoft.AspNetCore.Connections.Abstractions": "7.0.3"
+          "Microsoft.AspNetCore.Connections.Abstractions": "7.0.9"
         }
       },
       "Microsoft.AspNetCore.SignalR.Client.Core": {
         "type": "Transitive",
-        "resolved": "7.0.3",
-        "contentHash": "QOwhLtPcuHEqT/pkxF6qRnclaG28Pq4nlvimlEMpEAuiq/+PIM2TX/pGuZ3CO+yrWwuSlqA7Jg6AWA3hoUleuw==",
+        "resolved": "7.0.9",
+        "contentHash": "+rWA87MIbhUQi2fKJUTc9cRJgvHfZJdJ32gnIUxCHZtvwAHtfLG7Lc7KnJXiur6yLtG0CqLOANSfBXr/OOUqQw==",
         "dependencies": {
-          "Microsoft.AspNetCore.SignalR.Common": "7.0.3",
-          "Microsoft.AspNetCore.SignalR.Protocols.Json": "7.0.3",
+          "Microsoft.AspNetCore.SignalR.Common": "7.0.9",
+          "Microsoft.AspNetCore.SignalR.Protocols.Json": "7.0.9",
           "Microsoft.Extensions.DependencyInjection": "7.0.0",
           "Microsoft.Extensions.Logging": "7.0.0",
           "System.Threading.Channels": "7.0.0"
@@ -154,33 +154,33 @@
       },
       "Microsoft.AspNetCore.SignalR.Common": {
         "type": "Transitive",
-        "resolved": "7.0.3",
-        "contentHash": "Pm4+wwzr7ULOQrq6tlLuLMdtY2YYtXkHz90oMM+CSpV1SKlvfLabRSevd4No2U7PVykAan7kYgKN8HSxTYf1RA==",
+        "resolved": "7.0.9",
+        "contentHash": "u+mm77kNAm2qDjyXhZJnNDY1g0W6Tql4TbLClITMIK54KmOn+lzTsdpFBb41qJ1Jr1Qj+nC0yAXffEga008T6g==",
         "dependencies": {
-          "Microsoft.AspNetCore.Connections.Abstractions": "7.0.3",
+          "Microsoft.AspNetCore.Connections.Abstractions": "7.0.9",
           "Microsoft.Extensions.Options": "7.0.1"
         }
       },
       "Microsoft.AspNetCore.SignalR.Protocols.Json": {
         "type": "Transitive",
-        "resolved": "7.0.3",
-        "contentHash": "+DUVF6FdhMC4v7g/QaNor7hVBCy7+FHjC6h2HByvHxqJSj7i1y/m/4hg8nOTMhrj9BDbPeGK0b9NRrtrM83QmQ==",
+        "resolved": "7.0.9",
+        "contentHash": "0kUF1Vqd+Z42ihHjSShSQfayheIZENn1WamPMd/PB9+upEYP8jJDr+8MtCeYNwzxhmzu2UqfgeTeWJ96SZpzhQ==",
         "dependencies": {
-          "Microsoft.AspNetCore.SignalR.Common": "7.0.3"
+          "Microsoft.AspNetCore.SignalR.Common": "7.0.9"
         }
       },
       "Microsoft.AspNetCore.SpaServices.Extensions": {
         "type": "Transitive",
-        "resolved": "7.0.3",
-        "contentHash": "dh2ZmM+4Y3b3pCBndGuvpBIp1OOOK+CeZruphDKoFWgmxfN/RYmV0QvOS3u1McVb1yFmcVcXgVx0DEW5+WRkFg==",
+        "resolved": "7.0.9",
+        "contentHash": "qPuCTxQD832rs6vH5W8nVowU+BiWh45uH8+WnhEFiqgjGAGO8gw9Yf2aO4m0+VBi60kLwM8KYwYyLlpm0A8tbA==",
         "dependencies": {
           "Microsoft.Extensions.FileProviders.Physical": "7.0.0"
         }
       },
       "Microsoft.CodeCoverage": {
         "type": "Transitive",
-        "resolved": "17.5.0",
-        "contentHash": "6FQo0O6LKDqbCiIgVQhJAf810HSjFlOj7FunWaeOGDKxy8DAbpHzPk4SfBTXz9ytaaceuIIeR6hZgplt09m+ig=="
+        "resolved": "17.6.3",
+        "contentHash": "Gorg6F1dOxlI28yHYKhbQ3pOOfHeW6sUfsmwFQFaIV+xttUAZ+l8KarHIfsR+rBAnjY9VH71BXvPXBuObCkXsw=="
       },
       "Microsoft.CSharp": {
         "type": "Transitive",
@@ -189,19 +189,19 @@
       },
       "Microsoft.Data.Sqlite.Core": {
         "type": "Transitive",
-        "resolved": "7.0.3",
-        "contentHash": "pCmzLLWTIrIv94o7JtQ1qcPD0oc1YNY9XvlO6/tOF9YCcUfDZ3Tx9Z//CM7hFnprduHFPekif7jteBc/sXQ31Q==",
+        "resolved": "7.0.9",
+        "contentHash": "ow2PPoeW0yFc7NhexacQUw/LVjkO1mLK3VZAxhVIVjmQWlgYl/4mo9/U7uz+z75I+ZN6LUvq9M0ftU3IE75Ilg==",
         "dependencies": {
           "SQLitePCLRaw.core": "2.1.4"
         }
       },
       "Microsoft.EntityFrameworkCore": {
         "type": "Transitive",
-        "resolved": "7.0.3",
-        "contentHash": "6XUI2YGoaLMoP9KGaqWmmd4B2n5bpQbXrVRpH20Et3YjQ0Rn3Ia6HM/ANcSq9rBfjfUySgo9SwUZgQ4m4PF3LQ==",
+        "resolved": "7.0.9",
+        "contentHash": "9YuCdQWuRAmYYHqwW1h5ukKMC1fmNvcVHdp3gb8zdHxwSQz7hkGpYOBEjm6dRXRmGRkpUyHL8rwUz4kd53Ev0A==",
         "dependencies": {
-          "Microsoft.EntityFrameworkCore.Abstractions": "7.0.3",
-          "Microsoft.EntityFrameworkCore.Analyzers": "7.0.3",
+          "Microsoft.EntityFrameworkCore.Abstractions": "7.0.9",
+          "Microsoft.EntityFrameworkCore.Analyzers": "7.0.9",
           "Microsoft.Extensions.Caching.Memory": "7.0.0",
           "Microsoft.Extensions.DependencyInjection": "7.0.0",
           "Microsoft.Extensions.Logging": "7.0.0"
@@ -209,39 +209,39 @@
       },
       "Microsoft.EntityFrameworkCore.Abstractions": {
         "type": "Transitive",
-        "resolved": "7.0.3",
-        "contentHash": "0NaFBZykUlQHknddRuKY4v+MFZX/AW+v2V85dgj7abIlt+kL3GWa7QNH5S1084VLf1u+dq1SnhZsOvykc3Y0sA=="
+        "resolved": "7.0.9",
+        "contentHash": "cfY6Fn7cnP/5pXndL8QYaey/B2nGn1fwze5aSaMJymmbqwqYzFiszHuWbsdVBCDYJc8ok7eB1m/nCc3/rltulQ=="
       },
       "Microsoft.EntityFrameworkCore.Analyzers": {
         "type": "Transitive",
-        "resolved": "7.0.3",
-        "contentHash": "CLyRWFLwaOUZNPEia/aBMzFxZqm/ITKt3B+yUFtrg4Ys5VF3n2gvneuItC9IhpeOcjfdSgu/yUKf8y/IsNHs5A=="
+        "resolved": "7.0.9",
+        "contentHash": "VvqFD3DdML6LhPCAR/lG2xNX66juylC8v57yUAAYnUSdEUOMRi3lNoT4OrNdG0rere3UOQPhvVl5FH2QdyoF8Q=="
       },
       "Microsoft.EntityFrameworkCore.Relational": {
         "type": "Transitive",
-        "resolved": "7.0.3",
-        "contentHash": "RxNNjtTrxsMtdBtgoXGRSy8uCXaBHaVzIonTeo7+Ys+N0yEWwhf2E74cxneyunMi13Ezlld10ecCHlDubEU/Pw==",
+        "resolved": "7.0.9",
+        "contentHash": "u7iN6cNd6SJUlpdk24JVIbkji/UbkEEQ7pXncTyT4eXXj+Hz2y4NSZFOAywPGcioIgX1YzbKWDiJhk7hjSFxBQ==",
         "dependencies": {
-          "Microsoft.EntityFrameworkCore": "7.0.3",
+          "Microsoft.EntityFrameworkCore": "7.0.9",
           "Microsoft.Extensions.Configuration.Abstractions": "7.0.0"
         }
       },
       "Microsoft.EntityFrameworkCore.Sqlite": {
         "type": "Transitive",
-        "resolved": "7.0.3",
-        "contentHash": "bNWgYJsT6+0I98RSIY0rZAYu6NkLFgPvWPO1LKTsBqG6iwhCW3M38JGDVOOSf69N9HUZz9ilKNLTyYKNRQkyGw==",
+        "resolved": "7.0.9",
+        "contentHash": "otP72XqfG9UdO0+3O2p1UEx4MLXSAulXc8tIvuq+v1KPtBj/GuMZeJIz+eCu09Xv6fJ6k7aUb8J/x+H0rqOjjA==",
         "dependencies": {
-          "Microsoft.EntityFrameworkCore.Sqlite.Core": "7.0.3",
+          "Microsoft.EntityFrameworkCore.Sqlite.Core": "7.0.9",
           "SQLitePCLRaw.bundle_e_sqlite3": "2.1.4"
         }
       },
       "Microsoft.EntityFrameworkCore.Sqlite.Core": {
         "type": "Transitive",
-        "resolved": "7.0.3",
-        "contentHash": "w0yzGr1IDY4sp+RkFnVlWVF1N2lmaJHCmzVldZ/e36bXdT95E8N7/+JYWwKm1nLWEpS6naaoNtqQO6buRHTfAg==",
+        "resolved": "7.0.9",
+        "contentHash": "uLy1swPL4AD6BQsu3SEIzSWKc6gIlmoSAYZX39VnbrqvzkGmMsgEgdCsJIZvdjAW/vzhPazACKMYna1iif04sg==",
         "dependencies": {
-          "Microsoft.Data.Sqlite.Core": "7.0.3",
-          "Microsoft.EntityFrameworkCore.Relational": "7.0.3",
+          "Microsoft.Data.Sqlite.Core": "7.0.9",
+          "Microsoft.EntityFrameworkCore.Relational": "7.0.9",
           "Microsoft.Extensions.DependencyModel": "7.0.0"
         }
       },
@@ -302,8 +302,8 @@
       },
       "Microsoft.Extensions.Features": {
         "type": "Transitive",
-        "resolved": "7.0.3",
-        "contentHash": "LnHGYYYi4dX5XcW1J4QPyMqfWw6cKF9Hp23f3BMTl3kytUGv19K6E9nWA83/SVahzVLwL+RrR02o14VUMBUlug=="
+        "resolved": "7.0.9",
+        "contentHash": "IJ2vdDt2OYCKyJ7ZJPIZKa4b0M0tsG36h0QUt1d/E8IMAnjIncI+1i9Am0nmheD/wpcVd9eDykiV4dklcwUd3Q=="
       },
       "Microsoft.Extensions.FileProviders.Abstractions": {
         "type": "Transitive",
@@ -349,8 +349,8 @@
       },
       "Microsoft.Extensions.Logging.Abstractions": {
         "type": "Transitive",
-        "resolved": "7.0.0",
-        "contentHash": "kmn78+LPVMOWeITUjIlfxUPDsI0R6G0RkeAMBmQxAJ7vBJn4q2dTva7pWi65ceN5vPGjJ9q/Uae2WKgvfktJAw=="
+        "resolved": "7.0.1",
+        "contentHash": "pkeBFx0vqMW/A3aUVHh7MPu3WkBhaVlezhSZeb1c9XD0vUReYH1TLFSy5MxJgZfmz5LZzYoErMorlYZiwpOoNA=="
       },
       "Microsoft.Extensions.Options": {
         "type": "Transitive",
@@ -368,8 +368,8 @@
       },
       "Microsoft.NETCore.Platforms": {
         "type": "Transitive",
-        "resolved": "5.0.0",
-        "contentHash": "VyPlqzH2wavqquTcYpkIIAQ6WdenuKoFN0BdYBbCWsclXacSOHNQn66Gt4z5NBqEYW0FAPm5rlvki9ZiCij5xQ=="
+        "resolved": "1.1.0",
+        "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A=="
       },
       "Microsoft.NETCore.Targets": {
         "type": "Transitive",
@@ -378,19 +378,19 @@
       },
       "Microsoft.TestPlatform.ObjectModel": {
         "type": "Transitive",
-        "resolved": "17.5.0",
-        "contentHash": "QwiBJcC/oEA1kojOaB0uPWOIo4i6BYuTBBYJVhUvmXkyYqZ2Ut/VZfgi+enf8LF8J4sjO98oRRFt39MiRorcIw==",
+        "resolved": "17.6.3",
+        "contentHash": "gSqtX3RvcFisaLPs6sKXdZkSwUix83NQ9nOU/w6pYrHTl+d8GsVHSL9rvDNxMgoV5BNOdyU7zK7JOfbSaVMDWQ==",
         "dependencies": {
-          "NuGet.Frameworks": "5.11.0",
+          "NuGet.Frameworks": "6.5.0",
           "System.Reflection.Metadata": "1.6.0"
         }
       },
       "Microsoft.TestPlatform.TestHost": {
         "type": "Transitive",
-        "resolved": "17.5.0",
-        "contentHash": "X86aikwp9d4SDcBChwzQYZihTPGEtMdDk+9t64emAl7N0Tq+OmlLAoW+Rs+2FB2k6QdUicSlT4QLO2xABRokaw==",
+        "resolved": "17.6.3",
+        "contentHash": "lrgRXKFfIZSPlhuoQGLtciO/osL+4oADYEYb0d5or7n7YyJATIWespq3lRgz2IQpRh6N7cm0DnCOWeZiCRGzxA==",
         "dependencies": {
-          "Microsoft.TestPlatform.ObjectModel": "17.5.0",
+          "Microsoft.TestPlatform.ObjectModel": "17.6.3",
           "Newtonsoft.Json": "13.0.1"
         }
       },
@@ -406,8 +406,8 @@
       },
       "Namotion.Reflection": {
         "type": "Transitive",
-        "resolved": "2.1.0",
-        "contentHash": "9t63RauDp+CWzMCcCRAGXLRqEVIw0djYisGaDWhgHuXSaz/Djjpp9gpumCWVLpuDHLNf4HUmYWJeBt4AUyJSWA==",
+        "resolved": "2.1.2",
+        "contentHash": "7tSHAzX8GWKy0qrW6OgQWD7kAZiqzhq+m1503qczuwuK6ZYhOGCQUxw+F3F4KkRM70aB6RMslsRVSCFeouIehw==",
         "dependencies": {
           "Microsoft.CSharp": "4.3.0"
         }
@@ -470,85 +470,85 @@
       },
       "NJsonSchema": {
         "type": "Transitive",
-        "resolved": "10.8.0",
-        "contentHash": "lChjsLWaxyvElh4WJjVhdIiCtx7rimYGFTxtSi2pAkZf0ZnKaXYIX484HCVyzbDDHejDZPgOrcfAJ3kqNSTONw==",
+        "resolved": "10.9.0",
+        "contentHash": "IBPo6Srxn2MEcIFM3HdM4QImrJbsIeujENQyzHL2Pv6wLsKSYAyAEilecRqaLOhoy3snEiPLx7hhv7opbhOxKQ==",
         "dependencies": {
-          "Namotion.Reflection": "2.1.0",
+          "Namotion.Reflection": "2.1.2",
           "Newtonsoft.Json": "9.0.1"
         }
       },
       "NJsonSchema.Yaml": {
         "type": "Transitive",
-        "resolved": "10.8.0",
-        "contentHash": "sygi7CN1AQHFNfpk/RRgsAMl+5tTM65Rajau3QPKoflJoZbxPQLBgZZrH7SMINumkW61JqDy6EhKfHsxngTzqw==",
+        "resolved": "10.9.0",
+        "contentHash": "lGOtsydEnX1mVLT2Cyre7mST7K7crveYtai9FGC2i7COKEMEFjsfbnUtgXSHKxS/bzxVEIjLa49tR6xw34WkEw==",
         "dependencies": {
           "Microsoft.CSharp": "4.4.1",
-          "NJsonSchema": "10.8.0",
+          "NJsonSchema": "10.9.0",
           "YamlDotNet": "11.2.1"
         }
       },
       "NSwag.Annotations": {
         "type": "Transitive",
-        "resolved": "13.18.2",
-        "contentHash": "2zDLZXEU1PVuvdMc1k+SIkLonp8vPRHAkto6qQfCeCPqhDaF82+FOWe3TLJIYBS8IC7aV/mT+eoqJLeZvXiN4A=="
+        "resolved": "13.19.0",
+        "contentHash": "ukpuIdZTczGUwyIAz954xiefikAUShtEoColOODeKilKGcB5CXn1Q6j3D/Er75z2+qMA3qieXb386xAyW2/v+Q=="
       },
       "NSwag.AspNetCore": {
         "type": "Transitive",
-        "resolved": "13.18.2",
-        "contentHash": "ueewpltbJpZGWgR3BkLXQRX1g7ZtzNl7sT5DI90lNufJhLWnjC5nAfxRTP4J8nqO1vR79zLYzzA5ZwsusZaTpA==",
+        "resolved": "13.19.0",
+        "contentHash": "7ZPAHrJCMPuDN3kE5bZIckWIVtorMj3YCU0bYu83CqVBsOGlxUy6VS8CnmLHlmAJ5gxEA+RD+KGepJvk3u+ygQ==",
         "dependencies": {
           "Microsoft.Extensions.ApiDescription.Server": "6.0.3",
           "Microsoft.Extensions.FileProviders.Embedded": "[7.0.0, 8.0.0)",
-          "NSwag.Annotations": "13.18.2",
-          "NSwag.Core": "13.18.2",
-          "NSwag.Core.Yaml": "13.18.2",
-          "NSwag.Generation": "13.18.2",
-          "NSwag.Generation.AspNetCore": "13.18.2"
+          "NSwag.Annotations": "13.19.0",
+          "NSwag.Core": "13.19.0",
+          "NSwag.Core.Yaml": "13.19.0",
+          "NSwag.Generation": "13.19.0",
+          "NSwag.Generation.AspNetCore": "13.19.0"
         }
       },
       "NSwag.Core": {
         "type": "Transitive",
-        "resolved": "13.18.2",
-        "contentHash": "QM6OnVtUkD62fi0lgFF65KbZ0Y8ToGD+Rb4W3Nz6ymCWWfVbMwkn8xUxIjWSLsOEYJCbLvBBeGt+oBNrJwxb+A==",
+        "resolved": "13.19.0",
+        "contentHash": "UkPCsEJ5WaE6mcEjarZZfMPyyRPHOWK9YuFvxNjrSHclCxVUZ7jgFtNGMX0wy0WvXkmA5b6H9IoaAJECMxcu/g==",
         "dependencies": {
-          "NJsonSchema": "10.8.0",
+          "NJsonSchema": "10.9.0",
           "Newtonsoft.Json": "10.0.1"
         }
       },
       "NSwag.Core.Yaml": {
         "type": "Transitive",
-        "resolved": "13.18.2",
-        "contentHash": "y+h+4jrN3BC0B3v1Qg0Fr3jrGTB5nR5RAn5ZG5qBveiazeB9VDfR5cP9URN6wb3VypahHRzYryUB2KFZuiS22A==",
+        "resolved": "13.19.0",
+        "contentHash": "F97HsEclpnydInq1Wrd74WDQePHQ7S+e4yKi4BiYi/hLy6s88YlpRFB3R5Bx64iRkGKesU/uGiovKtFevpW2Bw==",
         "dependencies": {
-          "NJsonSchema.Yaml": "10.8.0",
-          "NSwag.Core": "13.18.2",
+          "NJsonSchema.Yaml": "10.9.0",
+          "NSwag.Core": "13.19.0",
           "YamlDotNet": "11.2.1"
         }
       },
       "NSwag.Generation": {
         "type": "Transitive",
-        "resolved": "13.18.2",
-        "contentHash": "hmdi+kXwKTgxu2aYgaILDw2nKvco1/wEd1RXaVNIz4ck6yHTJ58ANVcUjN3ytmL//f+hKEKWZznTi4YQW7jtpQ==",
+        "resolved": "13.19.0",
+        "contentHash": "TdBY+P6WGElaHIAzYaT9te141y46UCUTjlGgGKi5eR4ZXHE/JhmrXXU8kr3zFHWw7gk6TeOKy5LnPlJaxNXb5Q==",
         "dependencies": {
-          "NJsonSchema": "10.8.0",
-          "NSwag.Core": "13.18.2",
+          "NJsonSchema": "10.9.0",
+          "NSwag.Core": "13.19.0",
           "Newtonsoft.Json": "10.0.1"
         }
       },
       "NSwag.Generation.AspNetCore": {
         "type": "Transitive",
-        "resolved": "13.18.2",
-        "contentHash": "D+yfRbXtv/wDan1oEkskGWejlCiUTdLlV93qynay1Xp1HmnC0XYspjLkfMGr9kqYX4CY9SrMUnDUsRNeyOoR/g==",
+        "resolved": "13.19.0",
+        "contentHash": "1ne3rqJtWTUbZuN/kyCTk1cT36ZLXZs2/tE6Kvhb8MA6GySbwSwaLIJ1eHFn6dksp5+Ic5EhI1ZL6wrqapQgsg==",
         "dependencies": {
           "Microsoft.Extensions.DependencyInjection.Abstractions": "[7.0.0, 8.0.0)",
           "Microsoft.Extensions.Options": "[7.0.0, 8.0.0)",
-          "NSwag.Generation": "13.18.2"
+          "NSwag.Generation": "13.19.0"
         }
       },
       "NuGet.Frameworks": {
         "type": "Transitive",
-        "resolved": "5.11.0",
-        "contentHash": "eaiXkUjC4NPcquGWzAGMXjuxvLwc6XGKMptSyOGQeT0X70BUZObuybJFZLA0OfTdueLd3US23NBPTBb6iF3V1Q=="
+        "resolved": "6.5.0",
+        "contentHash": "QWINE2x3MbTODsWT1Gh71GaGb5icBz4chS8VYvTgsBnsi8esgN6wtHhydd7fvToWECYGq7T4cgBBDiKD/363fg=="
       },
       "runtime.debian.8-x64.runtime.native.System.Security.Cryptography.OpenSsl": {
         "type": "Transitive",
@@ -659,12 +659,8 @@
       },
       "SixLabors.ImageSharp": {
         "type": "Transitive",
-        "resolved": "2.1.3",
-        "contentHash": "8yonNRWX3vUE9k29ta0Hbfa0AEc0hbDjSH/nZ3vOTJEXmY6hLnGsjDKoz96Z+AgOsrdkAu6PdL/Ebaf70aitzw==",
-        "dependencies": {
-          "System.Runtime.CompilerServices.Unsafe": "5.0.0",
-          "System.Text.Encoding.CodePages": "5.0.0"
-        }
+        "resolved": "3.0.1",
+        "contentHash": "o0v/J6SJwp3RFrzR29beGx0cK7xcMRgOyIuw8ZNLQyNnBhiyL/vIQKn7cfycthcWUPG3XezUjFwBWzkcUUDFbg=="
       },
       "SQLitePCLRaw.bundle_e_sqlite3": {
         "type": "Transitive",
@@ -1133,11 +1129,6 @@
           "Microsoft.NETCore.Targets": "1.1.0"
         }
       },
-      "System.Runtime.CompilerServices.Unsafe": {
-        "type": "Transitive",
-        "resolved": "5.0.0",
-        "contentHash": "ZD9TMpsmYJLrxbbmdvhwt9YEgG5WntEnZ/d1eH8JBX9LBp+Ju8BSBhUGbZMNVHHomWo2KVImJhTDl2hIgw/6MA=="
-      },
       "System.Runtime.Extensions": {
         "type": "Transitive",
         "resolved": "4.3.0",
@@ -1355,14 +1346,6 @@
           "System.Runtime": "4.3.0"
         }
       },
-      "System.Text.Encoding.CodePages": {
-        "type": "Transitive",
-        "resolved": "5.0.0",
-        "contentHash": "NyscU59xX6Uo91qvhOs2Ccho3AR2TnZPomo1Z0K6YpyztBPM/A5VbkzOO19sy3A3i1TtEnTxA7bCe3Us+r5MWg==",
-        "dependencies": {
-          "Microsoft.NETCore.Platforms": "5.0.0"
-        }
-      },
       "System.Text.Encoding.Extensions": {
         "type": "Transitive",
         "resolved": "4.3.0",
@@ -1487,30 +1470,30 @@
       },
       "xunit.analyzers": {
         "type": "Transitive",
-        "resolved": "1.0.0",
-        "contentHash": "BeO8hEgs/c8Ls2647fPfieMngncvf0D0xYNDfIO59MolxtCtVjFRd6SRc+7tj8VMqkVOuJcnc9eh4ngI2cAmLQ=="
+        "resolved": "1.2.0",
+        "contentHash": "d3dehV/DASLRlR8stWQmbPPjfYC2tct50Evav+OlsJMkfFqkhYvzO1k0s81lk0px8O0knZU/FqC8SqbXOtn+hw=="
       },
       "xunit.assert": {
         "type": "Transitive",
-        "resolved": "2.4.2",
-        "contentHash": "pxJISOFjn2XTTi1mcDCkRZrTFb9OtRRCtx2kZFNF51GdReLr1ls2rnyxvAS4JO247K3aNtflvh5Q0346K5BROA==",
+        "resolved": "2.5.0",
+        "contentHash": "wN84pKX5jzfpgJ0bB6arrCA/oelBeYLCpnQ9Wj5xGEVPydKzVSDY5tEatFLHE/rO0+0RC+I4H5igGE118jRh1w==",
         "dependencies": {
           "NETStandard.Library": "1.6.1"
         }
       },
       "xunit.core": {
         "type": "Transitive",
-        "resolved": "2.4.2",
-        "contentHash": "KB4yGCxNqIVyekhJLXtKSEq6BaXVp/JO3mbGVE1hxypZTLEe7h+sTbAhpA+yZW2dPtXTuiW+C1B2oxxHEkrmOw==",
+        "resolved": "2.5.0",
+        "contentHash": "dnV0Mn2s1C0y2m33AylQyMkEyhBQsL4R0302kwSGiEGuY3JwzEmhTa9pnghyMRPliYSs4fXfkEAP+5bKXryGFg==",
         "dependencies": {
-          "xunit.extensibility.core": "[2.4.2]",
-          "xunit.extensibility.execution": "[2.4.2]"
+          "xunit.extensibility.core": "[2.5.0]",
+          "xunit.extensibility.execution": "[2.5.0]"
         }
       },
       "xunit.extensibility.core": {
         "type": "Transitive",
-        "resolved": "2.4.2",
-        "contentHash": "W1BoXTIN1C6kpVSMw25huSet25ky6IAQUNovu3zGOGN/jWnbgSoTyCrlIhmXSg0tH5nEf8q7h3OjNHOjyu5PfA==",
+        "resolved": "2.5.0",
+        "contentHash": "xRm6NIV3i7I+LkjsAJ91Xz2fxJm/oMEi2CYq1G5HlGTgcK1Zo2wNbLO6nKX1VG5FZzXibSdoLwr/MofVvh3mFA==",
         "dependencies": {
           "NETStandard.Library": "1.6.1",
           "xunit.abstractions": "2.0.3"
@@ -1518,11 +1501,11 @@
       },
       "xunit.extensibility.execution": {
         "type": "Transitive",
-        "resolved": "2.4.2",
-        "contentHash": "CZmgcKkwpyo8FlupZdWpJCryrAOWLh1FBPG6gmVZuPQkGQsim/oL4PcP4nfrC2hHgXUFtluvaJ0Sp9PQKUMNpg==",
+        "resolved": "2.5.0",
+        "contentHash": "7+v2Bvp+1ew1iMGQVb1glICi8jcNdHbRUX6Ru0dmJBViGdjiS7kyqcX2VxleQhFbKNi+WF0an7/TeTXD283RlQ==",
         "dependencies": {
           "NETStandard.Library": "1.6.1",
-          "xunit.extensibility.core": "[2.4.2]"
+          "xunit.extensibility.core": "[2.5.0]"
         }
       },
       "YamlDotNet": {
@@ -1534,14 +1517,14 @@
         "type": "Project",
         "dependencies": {
           "AutoMapper": "[12.0.1, )",
-          "AutoMapper.Extensions.Microsoft.DependencyInjection": "[12.0.0, )",
-          "Markdig": "[0.30.4, )",
-          "Microsoft.AspNetCore.SpaServices.Extensions": "[7.0.3, )",
-          "Microsoft.EntityFrameworkCore": "[7.0.3, )",
-          "Microsoft.EntityFrameworkCore.Analyzers": "[7.0.3, )",
-          "Microsoft.EntityFrameworkCore.Sqlite": "[7.0.3, )",
-          "NSwag.AspNetCore": "[13.18.2, )",
-          "SixLabors.ImageSharp": "[2.1.3, )"
+          "AutoMapper.Extensions.Microsoft.DependencyInjection": "[12.0.1, )",
+          "Markdig": "[0.31.0, )",
+          "Microsoft.AspNetCore.SpaServices.Extensions": "[7.0.9, )",
+          "Microsoft.EntityFrameworkCore": "[7.0.9, )",
+          "Microsoft.EntityFrameworkCore.Analyzers": "[7.0.9, )",
+          "Microsoft.EntityFrameworkCore.Sqlite": "[7.0.9, )",
+          "NSwag.AspNetCore": "[13.19.0, )",
+          "SixLabors.ImageSharp": "[3.0.1, )"
         }
       }
     }
diff --git a/BackEnd/Timeline/Timeline.csproj b/BackEnd/Timeline/Timeline.csproj
index 6000cdb5..92fc6dec 100644
--- a/BackEnd/Timeline/Timeline.csproj
+++ b/BackEnd/Timeline/Timeline.csproj
@@ -29,18 +29,18 @@
 
     <ItemGroup>
         <PackageReference Include="AutoMapper" Version="12.0.1" />
-        <PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.0" />
-        <PackageReference Include="Markdig" Version="0.30.4" />
-        <PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="7.0.3" />
-        <PackageReference Include="Microsoft.EntityFrameworkCore" Version="7.0.3" />
-        <PackageReference Include="Microsoft.EntityFrameworkCore.Analyzers" Version="7.0.3" />
-        <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.3" />
-        <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.3">
+        <PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" />
+        <PackageReference Include="Markdig" Version="0.31.0" />
+        <PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="7.0.9" />
+        <PackageReference Include="Microsoft.EntityFrameworkCore" Version="7.0.9" />
+        <PackageReference Include="Microsoft.EntityFrameworkCore.Analyzers" Version="7.0.9" />
+        <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.9" />
+        <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.9">
           <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
           <PrivateAssets>all</PrivateAssets>
         </PackageReference>
-        <PackageReference Include="NSwag.AspNetCore" Version="13.18.2" />
-        <PackageReference Include="SixLabors.ImageSharp" Version="2.1.3" />
+        <PackageReference Include="NSwag.AspNetCore" Version="13.19.0" />
+        <PackageReference Include="SixLabors.ImageSharp" Version="3.0.1" />
     </ItemGroup>
 
     <ItemGroup>
diff --git a/BackEnd/Timeline/packages.lock.json b/BackEnd/Timeline/packages.lock.json
index 3dc3a828..e6716f52 100644
--- a/BackEnd/Timeline/packages.lock.json
+++ b/BackEnd/Timeline/packages.lock.json
@@ -13,37 +13,37 @@
       },
       "AutoMapper.Extensions.Microsoft.DependencyInjection": {
         "type": "Direct",
-        "requested": "[12.0.0, )",
-        "resolved": "12.0.0",
-        "contentHash": "XCJ4E3oKrbRl1qY9Mr+7uyC0xZj1+bqQjmQRWTiTKiVuuXTny+7YFWHi20tPjwkMukLbicN6yGlDy5PZ4wyi1w==",
+        "requested": "[12.0.1, )",
+        "resolved": "12.0.1",
+        "contentHash": "+g/K+Vpe3gGMKGzjslMOdqNlkikScDjWfVvmWTayrDHaG/n2pPmFBMa+jKX1r/h6BDGFdkyRjAuhFE3ykW+r1g==",
         "dependencies": {
-          "AutoMapper": "12.0.0",
+          "AutoMapper": "[12.0.1]",
           "Microsoft.Extensions.Options": "6.0.0"
         }
       },
       "Markdig": {
         "type": "Direct",
-        "requested": "[0.30.4, )",
-        "resolved": "0.30.4",
-        "contentHash": "9LL7Mzubwf6QmOTZIqf+vERiinqPA1H+irBA38POhr93W/it00AvFWaCtF80+8VJTooFmcAJyZT2f60t1f88eQ=="
+        "requested": "[0.31.0, )",
+        "resolved": "0.31.0",
+        "contentHash": "CEdZ3KyFfz5IPMrofhCOSrc4O2KNvSXSMXt3fv0W5AFCsk6a/JhKZuTBxGSadNt8n4zyyd/xXfoUMvr4ZbZ4Fw=="
       },
       "Microsoft.AspNetCore.SpaServices.Extensions": {
         "type": "Direct",
-        "requested": "[7.0.3, )",
-        "resolved": "7.0.3",
-        "contentHash": "dh2ZmM+4Y3b3pCBndGuvpBIp1OOOK+CeZruphDKoFWgmxfN/RYmV0QvOS3u1McVb1yFmcVcXgVx0DEW5+WRkFg==",
+        "requested": "[7.0.9, )",
+        "resolved": "7.0.9",
+        "contentHash": "qPuCTxQD832rs6vH5W8nVowU+BiWh45uH8+WnhEFiqgjGAGO8gw9Yf2aO4m0+VBi60kLwM8KYwYyLlpm0A8tbA==",
         "dependencies": {
           "Microsoft.Extensions.FileProviders.Physical": "7.0.0"
         }
       },
       "Microsoft.EntityFrameworkCore": {
         "type": "Direct",
-        "requested": "[7.0.3, )",
-        "resolved": "7.0.3",
-        "contentHash": "6XUI2YGoaLMoP9KGaqWmmd4B2n5bpQbXrVRpH20Et3YjQ0Rn3Ia6HM/ANcSq9rBfjfUySgo9SwUZgQ4m4PF3LQ==",
+        "requested": "[7.0.9, )",
+        "resolved": "7.0.9",
+        "contentHash": "9YuCdQWuRAmYYHqwW1h5ukKMC1fmNvcVHdp3gb8zdHxwSQz7hkGpYOBEjm6dRXRmGRkpUyHL8rwUz4kd53Ev0A==",
         "dependencies": {
-          "Microsoft.EntityFrameworkCore.Abstractions": "7.0.3",
-          "Microsoft.EntityFrameworkCore.Analyzers": "7.0.3",
+          "Microsoft.EntityFrameworkCore.Abstractions": "7.0.9",
+          "Microsoft.EntityFrameworkCore.Analyzers": "7.0.9",
           "Microsoft.Extensions.Caching.Memory": "7.0.0",
           "Microsoft.Extensions.DependencyInjection": "7.0.0",
           "Microsoft.Extensions.Logging": "7.0.0"
@@ -51,53 +51,49 @@
       },
       "Microsoft.EntityFrameworkCore.Analyzers": {
         "type": "Direct",
-        "requested": "[7.0.3, )",
-        "resolved": "7.0.3",
-        "contentHash": "CLyRWFLwaOUZNPEia/aBMzFxZqm/ITKt3B+yUFtrg4Ys5VF3n2gvneuItC9IhpeOcjfdSgu/yUKf8y/IsNHs5A=="
+        "requested": "[7.0.9, )",
+        "resolved": "7.0.9",
+        "contentHash": "VvqFD3DdML6LhPCAR/lG2xNX66juylC8v57yUAAYnUSdEUOMRi3lNoT4OrNdG0rere3UOQPhvVl5FH2QdyoF8Q=="
       },
       "Microsoft.EntityFrameworkCore.Sqlite": {
         "type": "Direct",
-        "requested": "[7.0.3, )",
-        "resolved": "7.0.3",
-        "contentHash": "bNWgYJsT6+0I98RSIY0rZAYu6NkLFgPvWPO1LKTsBqG6iwhCW3M38JGDVOOSf69N9HUZz9ilKNLTyYKNRQkyGw==",
+        "requested": "[7.0.9, )",
+        "resolved": "7.0.9",
+        "contentHash": "otP72XqfG9UdO0+3O2p1UEx4MLXSAulXc8tIvuq+v1KPtBj/GuMZeJIz+eCu09Xv6fJ6k7aUb8J/x+H0rqOjjA==",
         "dependencies": {
-          "Microsoft.EntityFrameworkCore.Sqlite.Core": "7.0.3",
+          "Microsoft.EntityFrameworkCore.Sqlite.Core": "7.0.9",
           "SQLitePCLRaw.bundle_e_sqlite3": "2.1.4"
         }
       },
       "Microsoft.EntityFrameworkCore.Tools": {
         "type": "Direct",
-        "requested": "[7.0.3, )",
-        "resolved": "7.0.3",
-        "contentHash": "yHFlYPZS3Jx7JMCQnGKfJzv95rJWVcmcUn/OW5cbCyWgQk81JJpTZ9Q6kkvwquYjFRfvYHBGuXNIYhAJokOBTQ==",
+        "requested": "[7.0.9, )",
+        "resolved": "7.0.9",
+        "contentHash": "GWOSIe8ltR/mqv2wpRvMhy6ULapdKhZXXpXSGWzG1fRxhYXjSGpe2Pqhxqo46o9RDGlk0WJUXrwyU+N4voPxMw==",
         "dependencies": {
-          "Microsoft.EntityFrameworkCore.Design": "7.0.3"
+          "Microsoft.EntityFrameworkCore.Design": "7.0.9"
         }
       },
       "NSwag.AspNetCore": {
         "type": "Direct",
-        "requested": "[13.18.2, )",
-        "resolved": "13.18.2",
-        "contentHash": "ueewpltbJpZGWgR3BkLXQRX1g7ZtzNl7sT5DI90lNufJhLWnjC5nAfxRTP4J8nqO1vR79zLYzzA5ZwsusZaTpA==",
+        "requested": "[13.19.0, )",
+        "resolved": "13.19.0",
+        "contentHash": "7ZPAHrJCMPuDN3kE5bZIckWIVtorMj3YCU0bYu83CqVBsOGlxUy6VS8CnmLHlmAJ5gxEA+RD+KGepJvk3u+ygQ==",
         "dependencies": {
           "Microsoft.Extensions.ApiDescription.Server": "6.0.3",
           "Microsoft.Extensions.FileProviders.Embedded": "[7.0.0, 8.0.0)",
-          "NSwag.Annotations": "13.18.2",
-          "NSwag.Core": "13.18.2",
-          "NSwag.Core.Yaml": "13.18.2",
-          "NSwag.Generation": "13.18.2",
-          "NSwag.Generation.AspNetCore": "13.18.2"
+          "NSwag.Annotations": "13.19.0",
+          "NSwag.Core": "13.19.0",
+          "NSwag.Core.Yaml": "13.19.0",
+          "NSwag.Generation": "13.19.0",
+          "NSwag.Generation.AspNetCore": "13.19.0"
         }
       },
       "SixLabors.ImageSharp": {
         "type": "Direct",
-        "requested": "[2.1.3, )",
-        "resolved": "2.1.3",
-        "contentHash": "8yonNRWX3vUE9k29ta0Hbfa0AEc0hbDjSH/nZ3vOTJEXmY6hLnGsjDKoz96Z+AgOsrdkAu6PdL/Ebaf70aitzw==",
-        "dependencies": {
-          "System.Runtime.CompilerServices.Unsafe": "5.0.0",
-          "System.Text.Encoding.CodePages": "5.0.0"
-        }
+        "requested": "[3.0.1, )",
+        "resolved": "3.0.1",
+        "contentHash": "o0v/J6SJwp3RFrzR29beGx0cK7xcMRgOyIuw8ZNLQyNnBhiyL/vIQKn7cfycthcWUPG3XezUjFwBWzkcUUDFbg=="
       },
       "Humanizer.Core": {
         "type": "Transitive",
@@ -111,44 +107,44 @@
       },
       "Microsoft.Data.Sqlite.Core": {
         "type": "Transitive",
-        "resolved": "7.0.3",
-        "contentHash": "pCmzLLWTIrIv94o7JtQ1qcPD0oc1YNY9XvlO6/tOF9YCcUfDZ3Tx9Z//CM7hFnprduHFPekif7jteBc/sXQ31Q==",
+        "resolved": "7.0.9",
+        "contentHash": "ow2PPoeW0yFc7NhexacQUw/LVjkO1mLK3VZAxhVIVjmQWlgYl/4mo9/U7uz+z75I+ZN6LUvq9M0ftU3IE75Ilg==",
         "dependencies": {
           "SQLitePCLRaw.core": "2.1.4"
         }
       },
       "Microsoft.EntityFrameworkCore.Abstractions": {
         "type": "Transitive",
-        "resolved": "7.0.3",
-        "contentHash": "0NaFBZykUlQHknddRuKY4v+MFZX/AW+v2V85dgj7abIlt+kL3GWa7QNH5S1084VLf1u+dq1SnhZsOvykc3Y0sA=="
+        "resolved": "7.0.9",
+        "contentHash": "cfY6Fn7cnP/5pXndL8QYaey/B2nGn1fwze5aSaMJymmbqwqYzFiszHuWbsdVBCDYJc8ok7eB1m/nCc3/rltulQ=="
       },
       "Microsoft.EntityFrameworkCore.Design": {
         "type": "Transitive",
-        "resolved": "7.0.3",
-        "contentHash": "Nv0Y2Zh8d919qKq8Q1bvbWQbFeb4JQ7jCuakajSVtip5JIwt4hTIWetVIapJ2vOQDDZuAHCzkjimMOlHH5LVsQ==",
+        "resolved": "7.0.9",
+        "contentHash": "tWsa+spzKZAwHrUP6vYM1LLh0P89UMcldEjerFPPZb0LcI/ONQmh7ldK4Q8TeRuIiuXxYgRYPElSgxwLp14oug==",
         "dependencies": {
           "Humanizer.Core": "2.14.1",
-          "Microsoft.EntityFrameworkCore.Relational": "7.0.3",
+          "Microsoft.EntityFrameworkCore.Relational": "7.0.9",
           "Microsoft.Extensions.DependencyModel": "7.0.0",
           "Mono.TextTemplating": "2.2.1"
         }
       },
       "Microsoft.EntityFrameworkCore.Relational": {
         "type": "Transitive",
-        "resolved": "7.0.3",
-        "contentHash": "RxNNjtTrxsMtdBtgoXGRSy8uCXaBHaVzIonTeo7+Ys+N0yEWwhf2E74cxneyunMi13Ezlld10ecCHlDubEU/Pw==",
+        "resolved": "7.0.9",
+        "contentHash": "u7iN6cNd6SJUlpdk24JVIbkji/UbkEEQ7pXncTyT4eXXj+Hz2y4NSZFOAywPGcioIgX1YzbKWDiJhk7hjSFxBQ==",
         "dependencies": {
-          "Microsoft.EntityFrameworkCore": "7.0.3",
+          "Microsoft.EntityFrameworkCore": "7.0.9",
           "Microsoft.Extensions.Configuration.Abstractions": "7.0.0"
         }
       },
       "Microsoft.EntityFrameworkCore.Sqlite.Core": {
         "type": "Transitive",
-        "resolved": "7.0.3",
-        "contentHash": "w0yzGr1IDY4sp+RkFnVlWVF1N2lmaJHCmzVldZ/e36bXdT95E8N7/+JYWwKm1nLWEpS6naaoNtqQO6buRHTfAg==",
+        "resolved": "7.0.9",
+        "contentHash": "uLy1swPL4AD6BQsu3SEIzSWKc6gIlmoSAYZX39VnbrqvzkGmMsgEgdCsJIZvdjAW/vzhPazACKMYna1iif04sg==",
         "dependencies": {
-          "Microsoft.Data.Sqlite.Core": "7.0.3",
-          "Microsoft.EntityFrameworkCore.Relational": "7.0.3",
+          "Microsoft.Data.Sqlite.Core": "7.0.9",
+          "Microsoft.EntityFrameworkCore.Relational": "7.0.9",
           "Microsoft.Extensions.DependencyModel": "7.0.0"
         }
       },
@@ -270,8 +266,8 @@
       },
       "Microsoft.NETCore.Platforms": {
         "type": "Transitive",
-        "resolved": "5.0.0",
-        "contentHash": "VyPlqzH2wavqquTcYpkIIAQ6WdenuKoFN0BdYBbCWsclXacSOHNQn66Gt4z5NBqEYW0FAPm5rlvki9ZiCij5xQ=="
+        "resolved": "1.1.0",
+        "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A=="
       },
       "Microsoft.NETCore.Targets": {
         "type": "Transitive",
@@ -288,8 +284,8 @@
       },
       "Namotion.Reflection": {
         "type": "Transitive",
-        "resolved": "2.1.0",
-        "contentHash": "9t63RauDp+CWzMCcCRAGXLRqEVIw0djYisGaDWhgHuXSaz/Djjpp9gpumCWVLpuDHLNf4HUmYWJeBt4AUyJSWA==",
+        "resolved": "2.1.2",
+        "contentHash": "7tSHAzX8GWKy0qrW6OgQWD7kAZiqzhq+m1503qczuwuK6ZYhOGCQUxw+F3F4KkRM70aB6RMslsRVSCFeouIehw==",
         "dependencies": {
           "Microsoft.CSharp": "4.3.0"
         }
@@ -329,65 +325,65 @@
       },
       "NJsonSchema": {
         "type": "Transitive",
-        "resolved": "10.8.0",
-        "contentHash": "lChjsLWaxyvElh4WJjVhdIiCtx7rimYGFTxtSi2pAkZf0ZnKaXYIX484HCVyzbDDHejDZPgOrcfAJ3kqNSTONw==",
+        "resolved": "10.9.0",
+        "contentHash": "IBPo6Srxn2MEcIFM3HdM4QImrJbsIeujENQyzHL2Pv6wLsKSYAyAEilecRqaLOhoy3snEiPLx7hhv7opbhOxKQ==",
         "dependencies": {
-          "Namotion.Reflection": "2.1.0",
+          "Namotion.Reflection": "2.1.2",
           "Newtonsoft.Json": "9.0.1"
         }
       },
       "NJsonSchema.Yaml": {
         "type": "Transitive",
-        "resolved": "10.8.0",
-        "contentHash": "sygi7CN1AQHFNfpk/RRgsAMl+5tTM65Rajau3QPKoflJoZbxPQLBgZZrH7SMINumkW61JqDy6EhKfHsxngTzqw==",
+        "resolved": "10.9.0",
+        "contentHash": "lGOtsydEnX1mVLT2Cyre7mST7K7crveYtai9FGC2i7COKEMEFjsfbnUtgXSHKxS/bzxVEIjLa49tR6xw34WkEw==",
         "dependencies": {
           "Microsoft.CSharp": "4.4.1",
-          "NJsonSchema": "10.8.0",
+          "NJsonSchema": "10.9.0",
           "YamlDotNet": "11.2.1"
         }
       },
       "NSwag.Annotations": {
         "type": "Transitive",
-        "resolved": "13.18.2",
-        "contentHash": "2zDLZXEU1PVuvdMc1k+SIkLonp8vPRHAkto6qQfCeCPqhDaF82+FOWe3TLJIYBS8IC7aV/mT+eoqJLeZvXiN4A=="
+        "resolved": "13.19.0",
+        "contentHash": "ukpuIdZTczGUwyIAz954xiefikAUShtEoColOODeKilKGcB5CXn1Q6j3D/Er75z2+qMA3qieXb386xAyW2/v+Q=="
       },
       "NSwag.Core": {
         "type": "Transitive",
-        "resolved": "13.18.2",
-        "contentHash": "QM6OnVtUkD62fi0lgFF65KbZ0Y8ToGD+Rb4W3Nz6ymCWWfVbMwkn8xUxIjWSLsOEYJCbLvBBeGt+oBNrJwxb+A==",
+        "resolved": "13.19.0",
+        "contentHash": "UkPCsEJ5WaE6mcEjarZZfMPyyRPHOWK9YuFvxNjrSHclCxVUZ7jgFtNGMX0wy0WvXkmA5b6H9IoaAJECMxcu/g==",
         "dependencies": {
-          "NJsonSchema": "10.8.0",
+          "NJsonSchema": "10.9.0",
           "Newtonsoft.Json": "10.0.1"
         }
       },
       "NSwag.Core.Yaml": {
         "type": "Transitive",
-        "resolved": "13.18.2",
-        "contentHash": "y+h+4jrN3BC0B3v1Qg0Fr3jrGTB5nR5RAn5ZG5qBveiazeB9VDfR5cP9URN6wb3VypahHRzYryUB2KFZuiS22A==",
+        "resolved": "13.19.0",
+        "contentHash": "F97HsEclpnydInq1Wrd74WDQePHQ7S+e4yKi4BiYi/hLy6s88YlpRFB3R5Bx64iRkGKesU/uGiovKtFevpW2Bw==",
         "dependencies": {
-          "NJsonSchema.Yaml": "10.8.0",
-          "NSwag.Core": "13.18.2",
+          "NJsonSchema.Yaml": "10.9.0",
+          "NSwag.Core": "13.19.0",
           "YamlDotNet": "11.2.1"
         }
       },
       "NSwag.Generation": {
         "type": "Transitive",
-        "resolved": "13.18.2",
-        "contentHash": "hmdi+kXwKTgxu2aYgaILDw2nKvco1/wEd1RXaVNIz4ck6yHTJ58ANVcUjN3ytmL//f+hKEKWZznTi4YQW7jtpQ==",
+        "resolved": "13.19.0",
+        "contentHash": "TdBY+P6WGElaHIAzYaT9te141y46UCUTjlGgGKi5eR4ZXHE/JhmrXXU8kr3zFHWw7gk6TeOKy5LnPlJaxNXb5Q==",
         "dependencies": {
-          "NJsonSchema": "10.8.0",
-          "NSwag.Core": "13.18.2",
+          "NJsonSchema": "10.9.0",
+          "NSwag.Core": "13.19.0",
           "Newtonsoft.Json": "10.0.1"
         }
       },
       "NSwag.Generation.AspNetCore": {
         "type": "Transitive",
-        "resolved": "13.18.2",
-        "contentHash": "D+yfRbXtv/wDan1oEkskGWejlCiUTdLlV93qynay1Xp1HmnC0XYspjLkfMGr9kqYX4CY9SrMUnDUsRNeyOoR/g==",
+        "resolved": "13.19.0",
+        "contentHash": "1ne3rqJtWTUbZuN/kyCTk1cT36ZLXZs2/tE6Kvhb8MA6GySbwSwaLIJ1eHFn6dksp5+Ic5EhI1ZL6wrqapQgsg==",
         "dependencies": {
           "Microsoft.Extensions.DependencyInjection.Abstractions": "[7.0.0, 8.0.0)",
           "Microsoft.Extensions.Options": "[7.0.0, 8.0.0)",
-          "NSwag.Generation": "13.18.2"
+          "NSwag.Generation": "13.19.0"
         }
       },
       "SQLitePCLRaw.bundle_e_sqlite3": {
@@ -750,11 +746,6 @@
           "Microsoft.NETCore.Targets": "1.1.0"
         }
       },
-      "System.Runtime.CompilerServices.Unsafe": {
-        "type": "Transitive",
-        "resolved": "5.0.0",
-        "contentHash": "ZD9TMpsmYJLrxbbmdvhwt9YEgG5WntEnZ/d1eH8JBX9LBp+Ju8BSBhUGbZMNVHHomWo2KVImJhTDl2hIgw/6MA=="
-      },
       "System.Runtime.Extensions": {
         "type": "Transitive",
         "resolved": "4.3.0",
@@ -830,14 +821,6 @@
           "System.Runtime": "4.3.0"
         }
       },
-      "System.Text.Encoding.CodePages": {
-        "type": "Transitive",
-        "resolved": "5.0.0",
-        "contentHash": "NyscU59xX6Uo91qvhOs2Ccho3AR2TnZPomo1Z0K6YpyztBPM/A5VbkzOO19sy3A3i1TtEnTxA7bCe3Us+r5MWg==",
-        "dependencies": {
-          "Microsoft.NETCore.Platforms": "5.0.0"
-        }
-      },
       "System.Text.Encoding.Extensions": {
         "type": "Transitive",
         "resolved": "4.3.0",
diff --git a/FrontEnd/.eslintignore b/FrontEnd/.eslintignore
index 6fc7bee6..19d1b61c 100644
--- a/FrontEnd/.eslintignore
+++ b/FrontEnd/.eslintignore
@@ -1 +1,2 @@
 dist
+src/migrating
diff --git a/FrontEnd/src/App.tsx b/FrontEnd/src/App.tsx
index ca3e4d38..6029daac 100644
--- a/FrontEnd/src/App.tsx
+++ b/FrontEnd/src/App.tsx
@@ -10,8 +10,6 @@ import LoginPage from "./pages/login";
 import RegisterPage from "./pages/register";
 import TimelinePage from "./pages/timeline";
 import LoadingPage from "./pages/loading";
-import Search from "./views/search";
-import Admin from "./views/admin";
 import AlertHost from "./views/common/alert/AlertHost";
 
 export default function App() {
@@ -25,8 +23,6 @@ export default function App() {
           <Route path="register" element={<RegisterPage />} />
           <Route path="settings" element={<SettingPage />} />
           <Route path="about" element={<AboutPage />} />
-          <Route path="search" element={<Search />} />
-          <Route path="admin/*" element={<Admin />} />
           <Route path=":owner" element={<TimelinePage />} />
           <Route path=":owner/:timeline" element={<TimelinePage />} />
           <Route path="" element={<HomePage />} />
diff --git a/FrontEnd/src/migrating/admin/Admin.tsx b/FrontEnd/src/migrating/admin/Admin.tsx
new file mode 100644
index 00000000..986c36b4
--- /dev/null
+++ b/FrontEnd/src/migrating/admin/Admin.tsx
@@ -0,0 +1,27 @@
+import { Route, Routes } from "react-router-dom";
+import { useTranslation } from "react-i18next";
+
+import AdminNav from "./AdminNav";
+import UserAdmin from "./UserAdmin";
+import MoreAdmin from "./MoreAdmin";
+
+import "./index.css";
+
+const Admin: React.FC = () => {
+  useTranslation("admin");
+
+  return (
+    <>
+      <div className="container">
+        <AdminNav className="mt-2" />
+        <Routes>
+          <Route index element={<UserAdmin />} />
+          <Route path="user" element={<UserAdmin />} />
+          <Route path="more" element={<MoreAdmin />} />
+        </Routes>
+      </div>
+    </>
+  );
+};
+
+export default Admin;
diff --git a/FrontEnd/src/migrating/admin/AdminNav.tsx b/FrontEnd/src/migrating/admin/AdminNav.tsx
new file mode 100644
index 00000000..b7385e5c
--- /dev/null
+++ b/FrontEnd/src/migrating/admin/AdminNav.tsx
@@ -0,0 +1,29 @@
+import { useLocation } from "react-router-dom";
+
+import Tabs from "../common/tab/Tabs";
+
+export function AdminNav({ className }: { className?: string }) {
+  const location = useLocation();
+  const name = location.pathname.split("/")[2] ?? "user";
+
+  return (
+    <Tabs
+      className={className}
+      activeTabName={name}
+      tabs={[
+        {
+          name: "user",
+          text: "admin:nav.users",
+          link: "/admin/user",
+        },
+        {
+          name: "more",
+          text: "admin:nav.more",
+          link: "/admin/more",
+        },
+      ]}
+    />
+  );
+}
+
+export default AdminNav;
diff --git a/FrontEnd/src/migrating/admin/MoreAdmin.tsx b/FrontEnd/src/migrating/admin/MoreAdmin.tsx
new file mode 100644
index 00000000..d49d211f
--- /dev/null
+++ b/FrontEnd/src/migrating/admin/MoreAdmin.tsx
@@ -0,0 +1,7 @@
+import * as React from "react";
+
+const MoreAdmin: React.FC = () => {
+  return <>More...</>;
+};
+
+export default MoreAdmin;
diff --git a/FrontEnd/src/migrating/admin/UserAdmin.tsx b/FrontEnd/src/migrating/admin/UserAdmin.tsx
new file mode 100644
index 00000000..08560c87
--- /dev/null
+++ b/FrontEnd/src/migrating/admin/UserAdmin.tsx
@@ -0,0 +1,301 @@
+// eslint-disable
+// @ts-nocheck
+
+import { useState, useEffect } from "react";
+import * as React from "react";
+import { Trans, useTranslation } from "react-i18next";
+import classnames from "classnames";
+
+import { getHttpUserClient, HttpUser, kUserPermissionList } from "@/http/user";
+
+import OperationDialog from "../common/dialog/OperationDialog";
+import Button from "../common/button/Button";
+import Spinner from "../common/Spinner";
+import FlatButton from "../common/button/FlatButton";
+import IconButton from "../common/button/IconButton";
+
+const CreateUserDialog: React.FC<{
+  open: boolean;
+  close: () => void;
+  onSuccess: (user: HttpUser) => void;
+}> = ({ open, close, onSuccess }) => {
+  return (
+    <OperationDialog
+      title="admin:user.dialog.create.title"
+      inputPrompt="admin:user.dialog.create.prompt"
+      inputs={[
+        { key: "username", type: "text", label: "admin:user.username" },
+        { key: "password", type: "text", label: "admin:user.password" },
+      ]}
+      onProcess={({ username, password }) =>
+        getHttpUserClient().post({
+          username: username as string,
+          password: password as string,
+        })
+      }
+      onClose={close}
+      open={open}
+      onSuccessAndClose={onSuccess}
+    />
+  );
+};
+
+const UsernameLabel: React.FC<{ children: React.ReactNode }> = (props) => {
+  return <span style={{ color: "blue" }}>{props.children}</span>;
+};
+
+const UserDeleteDialog: React.FC<{
+  open: boolean;
+  close: () => void;
+  user: HttpUser;
+  onSuccess: () => void;
+}> = ({ open, close, user, onSuccess }) => {
+  return (
+    <OperationDialog
+      open={open}
+      onClose={close}
+      title="admin:user.dialog.delete.title"
+      themeColor="danger"
+      inputPrompt={() => (
+        <Trans i18nKey="user.dialog.delete.prompt" ns="admin">
+          0<UsernameLabel>{user.username}</UsernameLabel>2
+        </Trans>
+      )}
+      onProcess={() => getHttpUserClient().delete(user.username)}
+      onSuccessAndClose={onSuccess}
+    />
+  );
+};
+
+const UserModifyDialog: React.FC<{
+  open: boolean;
+  close: () => void;
+  user: HttpUser;
+  onSuccess: () => void;
+}> = ({ open, close, user, onSuccess }) => {
+  return (
+    <OperationDialog
+      open={open}
+      onClose={close}
+      title="admin:user.dialog.modify.title"
+      inputPromptNode={
+        <Trans i18nKey="admin:user.dialog.modify.prompt">
+          0<UsernameLabel>{user.username}</UsernameLabel>2
+        </Trans>
+      }
+      inputs={
+        [
+          {
+            type: "text",
+            label: "admin:user.username",
+            initValue: user.username,
+          },
+          { type: "text", label: "admin:user.password" },
+          {
+            type: "text",
+            label: "admin:user.nickname",
+            initValue: user.nickname,
+          },
+        ] as const
+      }
+      onProcess={([username, password, nickname]) =>
+        getHttpUserClient().patch(user.username, {
+          username: username !== user.username ? username : undefined,
+          password: password !== "" ? password : undefined,
+          nickname: nickname !== user.nickname ? nickname : undefined,
+        })
+      }
+      onSuccessAndClose={onSuccess}
+    />
+  );
+};
+
+const UserPermissionModifyDialog: React.FC<{
+  open: boolean;
+  close: () => void;
+  user: HttpUser;
+  onSuccess: () => void;
+}> = ({ open, close, user, onSuccess }) => {
+  const oldPermissionBoolList: boolean[] = kUserPermissionList.map(
+    (permission) => user.permissions.includes(permission),
+  );
+
+  return (
+    <OperationDialog
+      open={open}
+      onClose={close}
+      title="admin:user.dialog.modifyPermissions.title"
+      themeColor="danger"
+      inputPrompt={() => (
+        <Trans i18nKey="admin:user.dialog.modifyPermissions.prompt">
+          0<UsernameLabel>{user.username}</UsernameLabel>2
+        </Trans>
+      )}
+      inputScheme={kUserPermissionList.map<OperationDialogBoolInput>(
+        (permission, index) => ({
+          type: "bool",
+          label: { type: "custom", value: permission },
+          initValue: oldPermissionBoolList[index],
+        }),
+      )}
+      onProcess={async (newPermissionBoolList): Promise<boolean[]> => {
+        for (let index = 0; index < kUserPermissionList.length; index++) {
+          const oldValue = oldPermissionBoolList[index];
+          const newValue = newPermissionBoolList[index];
+          const permission = kUserPermissionList[index];
+          if (oldValue === newValue) continue;
+          if (newValue) {
+            await getHttpUserClient().putUserPermission(
+              user.username,
+              permission,
+            );
+          } else {
+            await getHttpUserClient().deleteUserPermission(
+              user.username,
+              permission,
+            );
+          }
+        }
+        return newPermissionBoolList;
+      }}
+      onSuccessAndClose={onSuccess}
+    />
+  );
+};
+
+interface UserItemProps {
+  user: HttpUser;
+  onChange: () => void;
+}
+
+const UserItem: React.FC<UserItemProps> = ({ user, onChange }) => {
+  const { t } = useTranslation();
+
+  const [dialog, setDialog] = useState<
+    "delete" | "modify" | "permission" | null
+  >(null);
+
+  const [editMaskVisible, setEditMaskVisible] = React.useState<boolean>(false);
+
+  return (
+    <>
+      <div className="admin-user-item">
+        <IconButton
+          icon="pencil-square"
+          className="cru-float-right"
+          onClick={() => setEditMaskVisible(true)}
+        />
+        <h5 className="cru-color-primary">{user.username}</h5>
+        <small className="d-block cru-color-secondary">
+          {t("admin:user.nickname")}
+          {user.nickname}
+        </small>
+        <small className="d-block cru-color-secondary">
+          {t("admin:user.uniqueId")}
+          {user.uniqueId}
+        </small>
+        <small className="d-block cru-color-secondary">
+          {t("admin:user.permissions")}
+          {user.permissions.map((permission) => {
+            return (
+              <span key={permission} className="cru-color-danger">
+                {permission}
+              </span>
+            );
+          })}
+        </small>
+        <div
+          className={classnames("edit-mask", !editMaskVisible && "d-none")}
+          onClick={() => setEditMaskVisible(false)}
+        >
+          <FlatButton
+            text="admin:user.modify"
+            onClick={() => setDialog("modify")}
+          />
+          <FlatButton
+            text="admin:user.modifyPermissions"
+            onClick={() => setDialog("permission")}
+          />
+          <FlatButton
+            text="admin:user.delete"
+            color="danger"
+            onClick={() => setDialog("delete")}
+          />
+        </div>
+      </div>
+      <UserDeleteDialog
+        open={dialog === "delete"}
+        close={() => setDialog(null)}
+        user={user}
+        onSuccess={onChange}
+      />
+      <UserModifyDialog
+        open={dialog === "modify"}
+        close={() => setDialog(null)}
+        user={user}
+        onSuccess={onChange}
+      />
+      <UserPermissionModifyDialog
+        open={dialog === "permission"}
+        close={() => setDialog(null)}
+        user={user}
+        onSuccess={onChange}
+      />
+    </>
+  );
+};
+
+const UserAdmin: React.FC = () => {
+  const [users, setUsers] = useState<HttpUser[] | null>(null);
+  const [dialog, setDialog] = useState<"create" | null>(null);
+  const [usersVersion, setUsersVersion] = useState<number>(0);
+  const updateUsers = (): void => {
+    setUsersVersion(usersVersion + 1);
+  };
+
+  useEffect(() => {
+    let subscribe = true;
+    void getHttpUserClient()
+      .list()
+      .then((us) => {
+        if (subscribe) {
+          setUsers(us.items);
+        }
+      });
+    return () => {
+      subscribe = false;
+    };
+  }, [usersVersion]);
+
+  if (users) {
+    const userComponents = users.map((user) => {
+      return (
+        <UserItem key={user.username} user={user} onChange={updateUsers} />
+      );
+    });
+
+    return (
+      <>
+        <div className="row justify-content-end my-2">
+          <div className="col col-auto">
+            <Button
+              text="admin:create"
+              color="success"
+              onClick={() => setDialog("create")}
+            />
+          </div>
+        </div>
+        {userComponents}
+        <CreateUserDialog
+          open={dialog === "create"}
+          close={() => setDialog(null)}
+          onSuccess={updateUsers}
+        />
+      </>
+    );
+  } else {
+    return <Spinner />;
+  }
+};
+
+export default UserAdmin;
diff --git a/FrontEnd/src/migrating/admin/index.css b/FrontEnd/src/migrating/admin/index.css
new file mode 100644
index 00000000..17e24586
--- /dev/null
+++ b/FrontEnd/src/migrating/admin/index.css
@@ -0,0 +1,33 @@
+.admin-user-item {
+  position: relative;
+  border: var(--cru-primary-color) solid;
+  border-width: 1px 1px 0;
+  padding: 1em;
+}
+
+.admin-user-item:last-of-type {
+  border-bottom-width: 1px;
+}
+
+.admin-user-item .edit-mask {
+  position: absolute;
+  top: 0;
+  left: 0;
+  bottom: 0;
+  right: 0;
+  background: rgba(255, 255, 255, 0.9);
+  position: absolute;
+  display: flex;
+  justify-content: space-around;
+  align-items: center;
+}
+
+@media (max-width: 576px) {
+  .admin-user-item .edit-mask {
+    flex-direction: column;
+  }
+}
+
+.admin-user-item .edit-mask button {
+  margin: 0.5em 2em;
+}
diff --git a/FrontEnd/src/migrating/admin/index.tsx b/FrontEnd/src/migrating/admin/index.tsx
new file mode 100644
index 00000000..0467711d
--- /dev/null
+++ b/FrontEnd/src/migrating/admin/index.tsx
@@ -0,0 +1,7 @@
+import { lazy } from "react";
+
+const Admin = lazy(
+  () => import(/* webpackChunkName: "admin" */ "./Admin")
+);
+
+export default Admin;
diff --git a/FrontEnd/src/migrating/center/CenterBoards.tsx b/FrontEnd/src/migrating/center/CenterBoards.tsx
new file mode 100644
index 00000000..a8be2c29
--- /dev/null
+++ b/FrontEnd/src/migrating/center/CenterBoards.tsx
@@ -0,0 +1,131 @@
+import * as React from "react";
+import { useTranslation } from "react-i18next";
+
+import { highlightTimelineUsername } from "@/common";
+
+import { pushAlert } from "@/services/alert";
+import { useUserLoggedIn } from "@/services/user";
+
+import { getHttpTimelineClient } from "@/http/timeline";
+import { getHttpBookmarkClient } from "@/http/bookmark";
+
+import TimelineBoard from "./TimelineBoard";
+
+const CenterBoards: React.FC = () => {
+  const { t } = useTranslation();
+
+  const user = useUserLoggedIn();
+
+  return (
+    <>
+      <div className="row justify-content-center">
+        <div className="col col-12 col-md-6">
+          <div className="row">
+            <div className="col col-12 my-2">
+              <TimelineBoard
+                title={t("home.bookmarkTimeline")}
+                load={() =>
+                  getHttpBookmarkClient()
+                    .list(user.username)
+                    .then((p) => p.items)
+                }
+                editHandler={{
+                  onDelete: (owner, timeline) => {
+                    return getHttpBookmarkClient()
+                      .delete(user.username, owner, timeline)
+                      .catch((e) => {
+                        pushAlert({
+                          message: "home.message.deleteBookmarkFail",
+                          type: "danger",
+                        });
+                        throw e;
+                      });
+                  },
+                  onMove: (owner, timeline, index, offset) => {
+                    return getHttpBookmarkClient()
+                      .move(
+                        user.username,
+                        owner,
+                        timeline,
+                        index + offset + 1 // +1 because backend contract: index starts at 1
+                      )
+                      .catch((e) => {
+                        pushAlert({
+                          message: "home.message.moveBookmarkFail",
+                          type: "danger",
+                        });
+                        throw e;
+                      })
+                      .then();
+                  },
+                }}
+              />
+            </div>
+            <div className="col col-12 my-2">
+              <TimelineBoard
+                title={t("home.highlightTimeline")}
+                load={() =>
+                  getHttpBookmarkClient()
+                    .list(highlightTimelineUsername)
+                    .then((p) => p.items)
+                }
+                editHandler={
+                  user.username === highlightTimelineUsername
+                    ? {
+                        onDelete: (owner, timeline) => {
+                          return getHttpBookmarkClient()
+                            .delete(highlightTimelineUsername, owner, timeline)
+                            .catch((e) => {
+                              pushAlert({
+                                message: "home.message.deleteHighlightFail",
+                                type: "danger",
+                              });
+                              throw e;
+                            });
+                        },
+                        onMove: (owner, timeline, index, offset) => {
+                          return getHttpBookmarkClient()
+                            .move(
+                              highlightTimelineUsername,
+                              owner,
+                              timeline,
+                              index + offset + 1 // +1 because backend contract: index starts at 1
+                            )
+                            .catch((e) => {
+                              pushAlert({
+                                message: "home.message.moveBookmarkFail",
+                                type: "danger",
+                              });
+                              throw e;
+                            })
+                            .then();
+                        },
+                      }
+                    : undefined
+                }
+              />
+            </div>
+          </div>
+        </div>
+        <div className="col-12 col-md-6 my-2">
+          <TimelineBoard
+            title={t("home.relatedTimeline")}
+            load={() =>
+              getHttpTimelineClient()
+                .listTimeline({ relate: user.username })
+                .then((l) =>
+                  l.map((t, index) => ({
+                    timelineOwner: t.owner.username,
+                    timelineName: t.nameV2,
+                    position: index + 1,
+                  }))
+                )
+            }
+          />
+        </div>
+      </div>
+    </>
+  );
+};
+
+export default CenterBoards;
diff --git a/FrontEnd/src/migrating/center/TimelineBoard.tsx b/FrontEnd/src/migrating/center/TimelineBoard.tsx
new file mode 100644
index 00000000..b3ccdf8c
--- /dev/null
+++ b/FrontEnd/src/migrating/center/TimelineBoard.tsx
@@ -0,0 +1,390 @@
+import * as React from "react";
+import classnames from "classnames";
+import { Link } from "react-router-dom";
+
+import { TimelineBookmark } from "@/http/bookmark";
+
+import TimelineLogo from "../common/TimelineLogo";
+import LoadFailReload from "../common/LoadFailReload";
+import FlatButton from "../common/button/FlatButton";
+import Card from "../common/Card";
+import Spinner from "../common/Spinner";
+import IconButton from "../common/button/IconButton";
+
+interface TimelineBoardItemProps {
+  timeline: TimelineBookmark;
+  // In height.
+  offset?: number;
+  // In px.
+  arbitraryOffset?: number;
+  // If not null, will disable navigation on click.
+  actions?: {
+    onDelete: () => void;
+    onMove: {
+      start: (e: React.PointerEvent) => void;
+      moving: (e: React.PointerEvent) => void;
+      end: (e: React.PointerEvent) => void;
+    };
+  };
+}
+
+const TimelineBoardItem: React.FC<TimelineBoardItemProps> = ({
+  timeline,
+  arbitraryOffset,
+  offset,
+  actions,
+}) => {
+  const content = (
+    <>
+      <TimelineLogo className="icon" />
+      <span className="title">
+        {timeline.timelineOwner}/{timeline.timelineName}
+      </span>
+      <span className="flex-grow-1"></span>
+      {actions != null ? (
+        <div className="right">
+          <IconButton
+            icon="trash"
+            color="danger"
+            className="px-2"
+            onClick={actions.onDelete}
+          />
+          <IconButton
+            icon="grip-vertical"
+            className="px-2 touch-action-none"
+            onPointerDown={(e) => {
+              e.currentTarget.setPointerCapture(e.pointerId);
+              actions.onMove.start(e);
+            }}
+            onPointerUp={(e) => {
+              actions.onMove.end(e);
+              try {
+                e.currentTarget.releasePointerCapture(e.pointerId);
+              } catch (_) {
+                void null;
+              }
+            }}
+            onPointerMove={actions.onMove.moving}
+          />
+        </div>
+      ) : null}
+    </>
+  );
+
+  const offsetStyle: React.CSSProperties = {
+    transform:
+      arbitraryOffset != null
+        ? `translate(0,${arbitraryOffset}px)`
+        : offset != null
+        ? `translate(0,${offset * 100}%)`
+        : undefined,
+    transition: offset != null ? "transform 0.5s" : undefined,
+    zIndex: arbitraryOffset != null ? 1 : undefined,
+  };
+
+  return actions == null ? (
+    <Link
+      to={`${timeline.timelineOwner}/${timeline.timelineName}`}
+      className="timeline-board-item"
+    >
+      {content}
+    </Link>
+  ) : (
+    <div style={offsetStyle} className="timeline-board-item">
+      {content}
+    </div>
+  );
+};
+
+interface TimelineBoardItemContainerProps {
+  timelines: TimelineBookmark[];
+  editHandler?: {
+    // offset may exceed index range plusing index.
+    onMove: (
+      owner: string,
+      timeline: string,
+      index: number,
+      offset: number
+    ) => void;
+    onDelete: (owner: string, timeline: string) => void;
+  };
+}
+
+const TimelineBoardItemContainer: React.FC<TimelineBoardItemContainerProps> = ({
+  timelines,
+  editHandler,
+}) => {
+  const [moveState, setMoveState] = React.useState<null | {
+    index: number;
+    offset: number;
+    startPointY: number;
+  }>(null);
+
+  return (
+    <>
+      {timelines.map((timeline, index) => {
+        const height = 48;
+
+        let offset: number | undefined = undefined;
+        let arbitraryOffset: number | undefined = undefined;
+        if (moveState != null) {
+          if (index === moveState.index) {
+            arbitraryOffset = moveState.offset;
+          } else {
+            if (moveState.offset >= 0) {
+              const offsetCount = Math.round(moveState.offset / height);
+              if (
+                index > moveState.index &&
+                index <= moveState.index + offsetCount
+              ) {
+                offset = -1;
+              } else {
+                offset = 0;
+              }
+            } else {
+              const offsetCount = Math.round(-moveState.offset / height);
+              if (
+                index < moveState.index &&
+                index >= moveState.index - offsetCount
+              ) {
+                offset = 1;
+              } else {
+                offset = 0;
+              }
+            }
+          }
+        }
+
+        return (
+          <TimelineBoardItem
+            key={timeline.timelineOwner + "/" + timeline.timelineName}
+            timeline={timeline}
+            offset={offset}
+            arbitraryOffset={arbitraryOffset}
+            actions={
+              editHandler != null
+                ? {
+                    onDelete: () => {
+                      editHandler.onDelete(
+                        timeline.timelineOwner,
+                        timeline.timelineName
+                      );
+                    },
+                    onMove: {
+                      start: (e) => {
+                        if (moveState != null) return;
+                        setMoveState({
+                          index,
+                          offset: 0,
+                          startPointY: e.clientY,
+                        });
+                      },
+                      moving: (e) => {
+                        if (moveState == null) return;
+                        setMoveState({
+                          index,
+                          offset: e.clientY - moveState.startPointY,
+                          startPointY: moveState.startPointY,
+                        });
+                      },
+                      end: () => {
+                        if (moveState != null) {
+                          const offsetCount = Math.round(
+                            moveState.offset / height
+                          );
+                          editHandler.onMove(
+                            timeline.timelineOwner,
+                            timeline.timelineName,
+                            moveState.index,
+                            offsetCount
+                          );
+                        }
+                        setMoveState(null);
+                      },
+                    },
+                  }
+                : undefined
+            }
+          />
+        );
+      })}
+    </>
+  );
+};
+
+interface TimelineBoardUIProps {
+  title?: string | null;
+  state: "offline" | "loading" | "loaded";
+  timelines: TimelineBookmark[];
+  onReload: () => void;
+  className?: string;
+  editHandler?: {
+    onMove: (
+      owner: string,
+      timeline: string,
+      index: number,
+      offset: number
+    ) => void;
+    onDelete: (owner: string, timeline: string) => void;
+  };
+}
+
+const TimelineBoardUI: React.FC<TimelineBoardUIProps> = (props) => {
+  const { title, state, timelines, className, editHandler } = props;
+
+  const editable = editHandler != null;
+
+  const [editing, setEditing] = React.useState<boolean>(false);
+
+  return (
+    <Card className={classnames("timeline-board", className)}>
+      <div className="timeline-board-header">
+        {title != null && <h3>{title}</h3>}
+        {editable &&
+          (editing ? (
+            <FlatButton
+              text="done"
+              onClick={() => {
+                setEditing(false);
+              }}
+            />
+          ) : (
+            <FlatButton
+              text="edit"
+              onClick={() => {
+                setEditing(true);
+              }}
+            />
+          ))}
+      </div>
+      {(() => {
+        if (state === "loading") {
+          return (
+            <div className="d-flex flex-grow-1 justify-content-center align-items-center">
+              <Spinner />
+            </div>
+          );
+        } else if (state === "offline") {
+          return (
+            <div className="d-flex flex-grow-1 justify-content-center align-items-center">
+              <LoadFailReload onReload={props.onReload} />
+            </div>
+          );
+        } else {
+          return (
+            <TimelineBoardItemContainer
+              timelines={timelines}
+              editHandler={
+                editHandler && editing
+                  ? {
+                      onDelete: editHandler.onDelete,
+                      onMove: (owner, timeline, index, offset) => {
+                        if (index + offset >= timelines.length) {
+                          offset = timelines.length - index - 1;
+                        } else if (index + offset < 0) {
+                          offset = -index;
+                        }
+                        editHandler.onMove(owner, timeline, index, offset);
+                      },
+                    }
+                  : undefined
+              }
+            />
+          );
+        }
+      })()}
+    </Card>
+  );
+};
+
+export interface TimelineBoardProps {
+  title?: string | null;
+  className?: string;
+  load: () => Promise<TimelineBookmark[]>;
+  editHandler?: {
+    onMove: (
+      owner: string,
+      timeline: string,
+      index: number,
+      offset: number
+    ) => Promise<void>;
+    onDelete: (owner: string, timeline: string) => Promise<void>;
+  };
+}
+
+const TimelineBoard: React.FC<TimelineBoardProps> = ({
+  className,
+  title,
+  load,
+  editHandler,
+}) => {
+  const [state, setState] = React.useState<"offline" | "loading" | "loaded">(
+    "loading"
+  );
+  const [timelines, setTimelines] = React.useState<TimelineBookmark[]>([]);
+
+  React.useEffect(() => {
+    let subscribe = true;
+    if (state === "loading") {
+      void load().then(
+        (timelines) => {
+          if (subscribe) {
+            setState("loaded");
+            setTimelines(timelines);
+          }
+        },
+        () => {
+          setState("offline");
+        }
+      );
+    }
+    return () => {
+      subscribe = false;
+    };
+  }, [load, state]);
+
+  return (
+    <TimelineBoardUI
+      title={title}
+      className={className}
+      state={state}
+      timelines={timelines}
+      onReload={() => {
+        setState("loaded");
+      }}
+      editHandler={
+        typeof timelines === "object" && editHandler != null
+          ? {
+              onMove: (owner, timeline, index, offset) => {
+                const newTimelines = timelines.slice();
+                const [t] = newTimelines.splice(index, 1);
+                newTimelines.splice(index + offset, 0, t);
+                setTimelines(newTimelines);
+                editHandler
+                  .onMove(owner, timeline, index, offset)
+                  .then(null, () => {
+                    setTimelines(timelines);
+                  });
+              },
+              onDelete: (owner, timeline) => {
+                const newTimelines = timelines.slice();
+                newTimelines.splice(
+                  timelines.findIndex(
+                    (t) =>
+                      t.timelineOwner === owner && t.timelineName === timeline
+                  ),
+                  1
+                );
+                setTimelines(newTimelines);
+                editHandler.onDelete(owner, timeline).then(null, () => {
+                  setTimelines(timelines);
+                });
+              },
+            }
+          : undefined
+      }
+    />
+  );
+};
+
+export default TimelineBoard;
diff --git a/FrontEnd/src/migrating/center/TimelineCreateDialog.tsx b/FrontEnd/src/migrating/center/TimelineCreateDialog.tsx
new file mode 100644
index 00000000..63742936
--- /dev/null
+++ b/FrontEnd/src/migrating/center/TimelineCreateDialog.tsx
@@ -0,0 +1,57 @@
+import * as React from "react";
+import { useNavigate } from "react-router-dom";
+
+import { validateTimelineName } from "@/services/timeline";
+import { getHttpTimelineClient, HttpTimelineInfo } from "@/http/timeline";
+
+import OperationDialog from "../common/dialog/OperationDialog";
+import { useUserLoggedIn } from "@/services/user";
+
+interface TimelineCreateDialogProps {
+  open: boolean;
+  close: () => void;
+}
+
+const TimelineCreateDialog: React.FC<TimelineCreateDialogProps> = (props) => {
+  const navigate = useNavigate();
+
+  const user = useUserLoggedIn();
+
+  return (
+    <OperationDialog
+      open={props.open}
+      onClose={props.close}
+      themeColor="success"
+      title="home.createDialog.title"
+      inputScheme={
+        [
+          {
+            type: "text",
+            label: "home.createDialog.name",
+            helperText: "home.createDialog.nameFormat",
+          },
+        ] as const
+      }
+      inputValidator={([name]) => {
+        if (name.length === 0) {
+          return { 0: "home.createDialog.noEmpty" };
+        } else if (name.length > 26) {
+          return { 0: "home.createDialog.tooLong" };
+        } else if (!validateTimelineName(name)) {
+          return { 0: "home.createDialog.badFormat" };
+        } else {
+          return null;
+        }
+      }}
+      onProcess={([name]): Promise<HttpTimelineInfo> =>
+        getHttpTimelineClient().postTimeline({ name })
+      }
+      onSuccessAndClose={(timeline: HttpTimelineInfo) => {
+        navigate(`${user.username}/${timeline.nameV2}`);
+      }}
+      failurePrompt={(e) => `${e as string}`}
+    />
+  );
+};
+
+export default TimelineCreateDialog;
diff --git a/FrontEnd/src/migrating/center/index.css b/FrontEnd/src/migrating/center/index.css
new file mode 100644
index 00000000..a779ff90
--- /dev/null
+++ b/FrontEnd/src/migrating/center/index.css
@@ -0,0 +1,43 @@
+.timeline-board {
+  min-height: 200px;
+  height: 100%;
+  position: relative;
+  padding: 1em 0;
+  display: flex;
+  flex-direction: column;
+}
+
+.timeline-board-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 0 1em;
+}
+
+.timeline-board-item {
+  font-size: 1.1em;
+  height: 48px;
+  transition: background 0.3s;
+  display: flex;
+  align-items: center;
+  padding: 0 1em;
+}
+
+.timeline-board-item .icon {
+  height: 1.3em;
+  color: black;
+}
+
+.timeline-board-item:hover {
+  background: #dee2e6;
+}
+.timeline-board-item .right {
+  display: flex;
+  align-items: center;
+  flex-shrink: 0;
+}
+.timeline-board-item .title {
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
diff --git a/FrontEnd/src/migrating/center/index.tsx b/FrontEnd/src/migrating/center/index.tsx
new file mode 100644
index 00000000..77af2c20
--- /dev/null
+++ b/FrontEnd/src/migrating/center/index.tsx
@@ -0,0 +1,60 @@
+import * as React from "react";
+import { useNavigate } from "react-router-dom";
+
+import { useUserLoggedIn } from "@/services/user";
+
+import SearchInput from "../common/SearchInput";
+import Button from "../common/button/Button";
+import CenterBoards from "./CenterBoards";
+import TimelineCreateDialog from "./TimelineCreateDialog";
+
+import "./index.css";
+
+const HomePage: React.FC = () => {
+  const navigate = useNavigate();
+
+  const user = useUserLoggedIn();
+
+  const [navText, setNavText] = React.useState<string>("");
+
+  const [dialog, setDialog] = React.useState<"create" | null>(null);
+
+  return (
+    <>
+      <div className="container">
+        <div className="row my-3 justify-content-center">
+          <div className="col col-12 col-md-8">
+            <SearchInput
+              className="justify-content-center"
+              value={navText}
+              onChange={setNavText}
+              onButtonClick={() => {
+                navigate(`search?q=${navText}`);
+              }}
+              additionalButton={
+                user != null && (
+                  <Button
+                    text="home.createButton"
+                    color="success"
+                    onClick={() => {
+                      setDialog("create");
+                    }}
+                  />
+                )
+              }
+            />
+          </div>
+        </div>
+        <CenterBoards />
+      </div>
+      <TimelineCreateDialog
+        open={dialog === "create"}
+        close={() => {
+          setDialog(null);
+        }}
+      />
+    </>
+  );
+};
+
+export default HomePage;
diff --git a/FrontEnd/src/services/alert.ts b/FrontEnd/src/services/alert.ts
index 42b14451..2f66dccc 100644
--- a/FrontEnd/src/services/alert.ts
+++ b/FrontEnd/src/services/alert.ts
@@ -1,10 +1,10 @@
 import pull from "lodash/pull";
 
 import { I18nText } from "@/common";
-import { PaletteColorType } from "@/palette";
+import { ThemeColor } from "@/views/common/common";
 
 export interface AlertInfo {
-  type?: PaletteColorType;
+  type?: ThemeColor;
   message?: I18nText;
   customMessage?: React.ReactElement;
   dismissTime?: number | "never";
diff --git a/FrontEnd/src/views/admin/Admin.tsx b/FrontEnd/src/views/admin/Admin.tsx
deleted file mode 100644
index 986c36b4..00000000
--- a/FrontEnd/src/views/admin/Admin.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import { Route, Routes } from "react-router-dom";
-import { useTranslation } from "react-i18next";
-
-import AdminNav from "./AdminNav";
-import UserAdmin from "./UserAdmin";
-import MoreAdmin from "./MoreAdmin";
-
-import "./index.css";
-
-const Admin: React.FC = () => {
-  useTranslation("admin");
-
-  return (
-    <>
-      <div className="container">
-        <AdminNav className="mt-2" />
-        <Routes>
-          <Route index element={<UserAdmin />} />
-          <Route path="user" element={<UserAdmin />} />
-          <Route path="more" element={<MoreAdmin />} />
-        </Routes>
-      </div>
-    </>
-  );
-};
-
-export default Admin;
diff --git a/FrontEnd/src/views/admin/AdminNav.tsx b/FrontEnd/src/views/admin/AdminNav.tsx
deleted file mode 100644
index b7385e5c..00000000
--- a/FrontEnd/src/views/admin/AdminNav.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-import { useLocation } from "react-router-dom";
-
-import Tabs from "../common/tab/Tabs";
-
-export function AdminNav({ className }: { className?: string }) {
-  const location = useLocation();
-  const name = location.pathname.split("/")[2] ?? "user";
-
-  return (
-    <Tabs
-      className={className}
-      activeTabName={name}
-      tabs={[
-        {
-          name: "user",
-          text: "admin:nav.users",
-          link: "/admin/user",
-        },
-        {
-          name: "more",
-          text: "admin:nav.more",
-          link: "/admin/more",
-        },
-      ]}
-    />
-  );
-}
-
-export default AdminNav;
diff --git a/FrontEnd/src/views/admin/MoreAdmin.tsx b/FrontEnd/src/views/admin/MoreAdmin.tsx
deleted file mode 100644
index d49d211f..00000000
--- a/FrontEnd/src/views/admin/MoreAdmin.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-import * as React from "react";
-
-const MoreAdmin: React.FC = () => {
-  return <>More...</>;
-};
-
-export default MoreAdmin;
diff --git a/FrontEnd/src/views/admin/UserAdmin.tsx b/FrontEnd/src/views/admin/UserAdmin.tsx
deleted file mode 100644
index d5179bf5..00000000
--- a/FrontEnd/src/views/admin/UserAdmin.tsx
+++ /dev/null
@@ -1,304 +0,0 @@
-import { useState, useEffect } from "react";
-import * as React from "react";
-import { Trans, useTranslation } from "react-i18next";
-import classnames from "classnames";
-
-import { getHttpUserClient, HttpUser, kUserPermissionList } from "@/http/user";
-
-import OperationDialog, {
-  OperationDialogBoolInput,
-} from "../common/dialog/OperationDialog";
-import Button from "../common/button/Button";
-import Spinner from "../common/Spinner";
-import FlatButton from "../common/button/FlatButton";
-import IconButton from "../common/button/IconButton";
-
-const CreateUserDialog: React.FC<{
-  open: boolean;
-  close: () => void;
-  onSuccess: (user: HttpUser) => void;
-}> = ({ open, close, onSuccess }) => {
-  return (
-    <OperationDialog
-      title="admin:user.dialog.create.title"
-      themeColor="success"
-      inputPrompt="admin:user.dialog.create.prompt"
-      inputScheme={
-        [
-          { type: "text", label: "admin:user.username" },
-          { type: "text", label: "admin:user.password" },
-        ] as const
-      }
-      onProcess={([username, password]) =>
-        getHttpUserClient().post({
-          username,
-          password,
-        })
-      }
-      onClose={close}
-      open={open}
-      onSuccessAndClose={onSuccess}
-    />
-  );
-};
-
-const UsernameLabel: React.FC<{ children: React.ReactNode }> = (props) => {
-  return <span style={{ color: "blue" }}>{props.children}</span>;
-};
-
-const UserDeleteDialog: React.FC<{
-  open: boolean;
-  close: () => void;
-  user: HttpUser;
-  onSuccess: () => void;
-}> = ({ open, close, user, onSuccess }) => {
-  return (
-    <OperationDialog
-      open={open}
-      onClose={close}
-      title="admin:user.dialog.delete.title"
-      themeColor="danger"
-      inputPrompt={() => (
-        <Trans i18nKey="user.dialog.delete.prompt" ns="admin">
-          0<UsernameLabel>{user.username}</UsernameLabel>2
-        </Trans>
-      )}
-      onProcess={() => getHttpUserClient().delete(user.username)}
-      onSuccessAndClose={onSuccess}
-    />
-  );
-};
-
-const UserModifyDialog: React.FC<{
-  open: boolean;
-  close: () => void;
-  user: HttpUser;
-  onSuccess: () => void;
-}> = ({ open, close, user, onSuccess }) => {
-  return (
-    <OperationDialog
-      open={open}
-      onClose={close}
-      title="admin:user.dialog.modify.title"
-      themeColor="danger"
-      inputPrompt={() => (
-        <Trans i18nKey="admin:user.dialog.modify.prompt">
-          0<UsernameLabel>{user.username}</UsernameLabel>2
-        </Trans>
-      )}
-      inputScheme={
-        [
-          {
-            type: "text",
-            label: "admin:user.username",
-            initValue: user.username,
-          },
-          { type: "text", label: "admin:user.password" },
-          {
-            type: "text",
-            label: "admin:user.nickname",
-            initValue: user.nickname,
-          },
-        ] as const
-      }
-      onProcess={([username, password, nickname]) =>
-        getHttpUserClient().patch(user.username, {
-          username: username !== user.username ? username : undefined,
-          password: password !== "" ? password : undefined,
-          nickname: nickname !== user.nickname ? nickname : undefined,
-        })
-      }
-      onSuccessAndClose={onSuccess}
-    />
-  );
-};
-
-const UserPermissionModifyDialog: React.FC<{
-  open: boolean;
-  close: () => void;
-  user: HttpUser;
-  onSuccess: () => void;
-}> = ({ open, close, user, onSuccess }) => {
-  const oldPermissionBoolList: boolean[] = kUserPermissionList.map(
-    (permission) => user.permissions.includes(permission)
-  );
-
-  return (
-    <OperationDialog
-      open={open}
-      onClose={close}
-      title="admin:user.dialog.modifyPermissions.title"
-      themeColor="danger"
-      inputPrompt={() => (
-        <Trans i18nKey="admin:user.dialog.modifyPermissions.prompt">
-          0<UsernameLabel>{user.username}</UsernameLabel>2
-        </Trans>
-      )}
-      inputScheme={kUserPermissionList.map<OperationDialogBoolInput>(
-        (permission, index) => ({
-          type: "bool",
-          label: { type: "custom", value: permission },
-          initValue: oldPermissionBoolList[index],
-        })
-      )}
-      onProcess={async (newPermissionBoolList): Promise<boolean[]> => {
-        for (let index = 0; index < kUserPermissionList.length; index++) {
-          const oldValue = oldPermissionBoolList[index];
-          const newValue = newPermissionBoolList[index];
-          const permission = kUserPermissionList[index];
-          if (oldValue === newValue) continue;
-          if (newValue) {
-            await getHttpUserClient().putUserPermission(
-              user.username,
-              permission
-            );
-          } else {
-            await getHttpUserClient().deleteUserPermission(
-              user.username,
-              permission
-            );
-          }
-        }
-        return newPermissionBoolList;
-      }}
-      onSuccessAndClose={onSuccess}
-    />
-  );
-};
-
-interface UserItemProps {
-  user: HttpUser;
-  onChange: () => void;
-}
-
-const UserItem: React.FC<UserItemProps> = ({ user, onChange }) => {
-  const { t } = useTranslation();
-
-  const [dialog, setDialog] = useState<
-    "delete" | "modify" | "permission" | null
-  >(null);
-
-  const [editMaskVisible, setEditMaskVisible] = React.useState<boolean>(false);
-
-  return (
-    <>
-      <div className="admin-user-item">
-        <IconButton
-          icon="pencil-square"
-          className="cru-float-right"
-          onClick={() => setEditMaskVisible(true)}
-        />
-        <h5 className="cru-color-primary">{user.username}</h5>
-        <small className="d-block cru-color-secondary">
-          {t("admin:user.nickname")}
-          {user.nickname}
-        </small>
-        <small className="d-block cru-color-secondary">
-          {t("admin:user.uniqueId")}
-          {user.uniqueId}
-        </small>
-        <small className="d-block cru-color-secondary">
-          {t("admin:user.permissions")}
-          {user.permissions.map((permission) => {
-            return (
-              <span key={permission} className="cru-color-danger">
-                {permission}
-              </span>
-            );
-          })}
-        </small>
-        <div
-          className={classnames("edit-mask", !editMaskVisible && "d-none")}
-          onClick={() => setEditMaskVisible(false)}
-        >
-          <FlatButton
-            text="admin:user.modify"
-            onClick={() => setDialog("modify")}
-          />
-          <FlatButton
-            text="admin:user.modifyPermissions"
-            onClick={() => setDialog("permission")}
-          />
-          <FlatButton
-            text="admin:user.delete"
-            color="danger"
-            onClick={() => setDialog("delete")}
-          />
-        </div>
-      </div>
-      <UserDeleteDialog
-        open={dialog === "delete"}
-        close={() => setDialog(null)}
-        user={user}
-        onSuccess={onChange}
-      />
-      <UserModifyDialog
-        open={dialog === "modify"}
-        close={() => setDialog(null)}
-        user={user}
-        onSuccess={onChange}
-      />
-      <UserPermissionModifyDialog
-        open={dialog === "permission"}
-        close={() => setDialog(null)}
-        user={user}
-        onSuccess={onChange}
-      />
-    </>
-  );
-};
-
-const UserAdmin: React.FC = () => {
-  const [users, setUsers] = useState<HttpUser[] | null>(null);
-  const [dialog, setDialog] = useState<"create" | null>(null);
-  const [usersVersion, setUsersVersion] = useState<number>(0);
-  const updateUsers = (): void => {
-    setUsersVersion(usersVersion + 1);
-  };
-
-  useEffect(() => {
-    let subscribe = true;
-    void getHttpUserClient()
-      .list()
-      .then((us) => {
-        if (subscribe) {
-          setUsers(us.items);
-        }
-      });
-    return () => {
-      subscribe = false;
-    };
-  }, [usersVersion]);
-
-  if (users) {
-    const userComponents = users.map((user) => {
-      return (
-        <UserItem key={user.username} user={user} onChange={updateUsers} />
-      );
-    });
-
-    return (
-      <>
-        <div className="row justify-content-end my-2">
-          <div className="col col-auto">
-            <Button
-              text="admin:create"
-              color="success"
-              onClick={() => setDialog("create")}
-            />
-          </div>
-        </div>
-        {userComponents}
-        <CreateUserDialog
-          open={dialog === "create"}
-          close={() => setDialog(null)}
-          onSuccess={updateUsers}
-        />
-      </>
-    );
-  } else {
-    return <Spinner />;
-  }
-};
-
-export default UserAdmin;
diff --git a/FrontEnd/src/views/admin/index.css b/FrontEnd/src/views/admin/index.css
deleted file mode 100644
index 17e24586..00000000
--- a/FrontEnd/src/views/admin/index.css
+++ /dev/null
@@ -1,33 +0,0 @@
-.admin-user-item {
-  position: relative;
-  border: var(--cru-primary-color) solid;
-  border-width: 1px 1px 0;
-  padding: 1em;
-}
-
-.admin-user-item:last-of-type {
-  border-bottom-width: 1px;
-}
-
-.admin-user-item .edit-mask {
-  position: absolute;
-  top: 0;
-  left: 0;
-  bottom: 0;
-  right: 0;
-  background: rgba(255, 255, 255, 0.9);
-  position: absolute;
-  display: flex;
-  justify-content: space-around;
-  align-items: center;
-}
-
-@media (max-width: 576px) {
-  .admin-user-item .edit-mask {
-    flex-direction: column;
-  }
-}
-
-.admin-user-item .edit-mask button {
-  margin: 0.5em 2em;
-}
diff --git a/FrontEnd/src/views/admin/index.tsx b/FrontEnd/src/views/admin/index.tsx
deleted file mode 100644
index 0467711d..00000000
--- a/FrontEnd/src/views/admin/index.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-import { lazy } from "react";
-
-const Admin = lazy(
-  () => import(/* webpackChunkName: "admin" */ "./Admin")
-);
-
-export default Admin;
diff --git a/FrontEnd/src/views/center/CenterBoards.tsx b/FrontEnd/src/views/center/CenterBoards.tsx
deleted file mode 100644
index a8be2c29..00000000
--- a/FrontEnd/src/views/center/CenterBoards.tsx
+++ /dev/null
@@ -1,131 +0,0 @@
-import * as React from "react";
-import { useTranslation } from "react-i18next";
-
-import { highlightTimelineUsername } from "@/common";
-
-import { pushAlert } from "@/services/alert";
-import { useUserLoggedIn } from "@/services/user";
-
-import { getHttpTimelineClient } from "@/http/timeline";
-import { getHttpBookmarkClient } from "@/http/bookmark";
-
-import TimelineBoard from "./TimelineBoard";
-
-const CenterBoards: React.FC = () => {
-  const { t } = useTranslation();
-
-  const user = useUserLoggedIn();
-
-  return (
-    <>
-      <div className="row justify-content-center">
-        <div className="col col-12 col-md-6">
-          <div className="row">
-            <div className="col col-12 my-2">
-              <TimelineBoard
-                title={t("home.bookmarkTimeline")}
-                load={() =>
-                  getHttpBookmarkClient()
-                    .list(user.username)
-                    .then((p) => p.items)
-                }
-                editHandler={{
-                  onDelete: (owner, timeline) => {
-                    return getHttpBookmarkClient()
-                      .delete(user.username, owner, timeline)
-                      .catch((e) => {
-                        pushAlert({
-                          message: "home.message.deleteBookmarkFail",
-                          type: "danger",
-                        });
-                        throw e;
-                      });
-                  },
-                  onMove: (owner, timeline, index, offset) => {
-                    return getHttpBookmarkClient()
-                      .move(
-                        user.username,
-                        owner,
-                        timeline,
-                        index + offset + 1 // +1 because backend contract: index starts at 1
-                      )
-                      .catch((e) => {
-                        pushAlert({
-                          message: "home.message.moveBookmarkFail",
-                          type: "danger",
-                        });
-                        throw e;
-                      })
-                      .then();
-                  },
-                }}
-              />
-            </div>
-            <div className="col col-12 my-2">
-              <TimelineBoard
-                title={t("home.highlightTimeline")}
-                load={() =>
-                  getHttpBookmarkClient()
-                    .list(highlightTimelineUsername)
-                    .then((p) => p.items)
-                }
-                editHandler={
-                  user.username === highlightTimelineUsername
-                    ? {
-                        onDelete: (owner, timeline) => {
-                          return getHttpBookmarkClient()
-                            .delete(highlightTimelineUsername, owner, timeline)
-                            .catch((e) => {
-                              pushAlert({
-                                message: "home.message.deleteHighlightFail",
-                                type: "danger",
-                              });
-                              throw e;
-                            });
-                        },
-                        onMove: (owner, timeline, index, offset) => {
-                          return getHttpBookmarkClient()
-                            .move(
-                              highlightTimelineUsername,
-                              owner,
-                              timeline,
-                              index + offset + 1 // +1 because backend contract: index starts at 1
-                            )
-                            .catch((e) => {
-                              pushAlert({
-                                message: "home.message.moveBookmarkFail",
-                                type: "danger",
-                              });
-                              throw e;
-                            })
-                            .then();
-                        },
-                      }
-                    : undefined
-                }
-              />
-            </div>
-          </div>
-        </div>
-        <div className="col-12 col-md-6 my-2">
-          <TimelineBoard
-            title={t("home.relatedTimeline")}
-            load={() =>
-              getHttpTimelineClient()
-                .listTimeline({ relate: user.username })
-                .then((l) =>
-                  l.map((t, index) => ({
-                    timelineOwner: t.owner.username,
-                    timelineName: t.nameV2,
-                    position: index + 1,
-                  }))
-                )
-            }
-          />
-        </div>
-      </div>
-    </>
-  );
-};
-
-export default CenterBoards;
diff --git a/FrontEnd/src/views/center/TimelineBoard.tsx b/FrontEnd/src/views/center/TimelineBoard.tsx
deleted file mode 100644
index b3ccdf8c..00000000
--- a/FrontEnd/src/views/center/TimelineBoard.tsx
+++ /dev/null
@@ -1,390 +0,0 @@
-import * as React from "react";
-import classnames from "classnames";
-import { Link } from "react-router-dom";
-
-import { TimelineBookmark } from "@/http/bookmark";
-
-import TimelineLogo from "../common/TimelineLogo";
-import LoadFailReload from "../common/LoadFailReload";
-import FlatButton from "../common/button/FlatButton";
-import Card from "../common/Card";
-import Spinner from "../common/Spinner";
-import IconButton from "../common/button/IconButton";
-
-interface TimelineBoardItemProps {
-  timeline: TimelineBookmark;
-  // In height.
-  offset?: number;
-  // In px.
-  arbitraryOffset?: number;
-  // If not null, will disable navigation on click.
-  actions?: {
-    onDelete: () => void;
-    onMove: {
-      start: (e: React.PointerEvent) => void;
-      moving: (e: React.PointerEvent) => void;
-      end: (e: React.PointerEvent) => void;
-    };
-  };
-}
-
-const TimelineBoardItem: React.FC<TimelineBoardItemProps> = ({
-  timeline,
-  arbitraryOffset,
-  offset,
-  actions,
-}) => {
-  const content = (
-    <>
-      <TimelineLogo className="icon" />
-      <span className="title">
-        {timeline.timelineOwner}/{timeline.timelineName}
-      </span>
-      <span className="flex-grow-1"></span>
-      {actions != null ? (
-        <div className="right">
-          <IconButton
-            icon="trash"
-            color="danger"
-            className="px-2"
-            onClick={actions.onDelete}
-          />
-          <IconButton
-            icon="grip-vertical"
-            className="px-2 touch-action-none"
-            onPointerDown={(e) => {
-              e.currentTarget.setPointerCapture(e.pointerId);
-              actions.onMove.start(e);
-            }}
-            onPointerUp={(e) => {
-              actions.onMove.end(e);
-              try {
-                e.currentTarget.releasePointerCapture(e.pointerId);
-              } catch (_) {
-                void null;
-              }
-            }}
-            onPointerMove={actions.onMove.moving}
-          />
-        </div>
-      ) : null}
-    </>
-  );
-
-  const offsetStyle: React.CSSProperties = {
-    transform:
-      arbitraryOffset != null
-        ? `translate(0,${arbitraryOffset}px)`
-        : offset != null
-        ? `translate(0,${offset * 100}%)`
-        : undefined,
-    transition: offset != null ? "transform 0.5s" : undefined,
-    zIndex: arbitraryOffset != null ? 1 : undefined,
-  };
-
-  return actions == null ? (
-    <Link
-      to={`${timeline.timelineOwner}/${timeline.timelineName}`}
-      className="timeline-board-item"
-    >
-      {content}
-    </Link>
-  ) : (
-    <div style={offsetStyle} className="timeline-board-item">
-      {content}
-    </div>
-  );
-};
-
-interface TimelineBoardItemContainerProps {
-  timelines: TimelineBookmark[];
-  editHandler?: {
-    // offset may exceed index range plusing index.
-    onMove: (
-      owner: string,
-      timeline: string,
-      index: number,
-      offset: number
-    ) => void;
-    onDelete: (owner: string, timeline: string) => void;
-  };
-}
-
-const TimelineBoardItemContainer: React.FC<TimelineBoardItemContainerProps> = ({
-  timelines,
-  editHandler,
-}) => {
-  const [moveState, setMoveState] = React.useState<null | {
-    index: number;
-    offset: number;
-    startPointY: number;
-  }>(null);
-
-  return (
-    <>
-      {timelines.map((timeline, index) => {
-        const height = 48;
-
-        let offset: number | undefined = undefined;
-        let arbitraryOffset: number | undefined = undefined;
-        if (moveState != null) {
-          if (index === moveState.index) {
-            arbitraryOffset = moveState.offset;
-          } else {
-            if (moveState.offset >= 0) {
-              const offsetCount = Math.round(moveState.offset / height);
-              if (
-                index > moveState.index &&
-                index <= moveState.index + offsetCount
-              ) {
-                offset = -1;
-              } else {
-                offset = 0;
-              }
-            } else {
-              const offsetCount = Math.round(-moveState.offset / height);
-              if (
-                index < moveState.index &&
-                index >= moveState.index - offsetCount
-              ) {
-                offset = 1;
-              } else {
-                offset = 0;
-              }
-            }
-          }
-        }
-
-        return (
-          <TimelineBoardItem
-            key={timeline.timelineOwner + "/" + timeline.timelineName}
-            timeline={timeline}
-            offset={offset}
-            arbitraryOffset={arbitraryOffset}
-            actions={
-              editHandler != null
-                ? {
-                    onDelete: () => {
-                      editHandler.onDelete(
-                        timeline.timelineOwner,
-                        timeline.timelineName
-                      );
-                    },
-                    onMove: {
-                      start: (e) => {
-                        if (moveState != null) return;
-                        setMoveState({
-                          index,
-                          offset: 0,
-                          startPointY: e.clientY,
-                        });
-                      },
-                      moving: (e) => {
-                        if (moveState == null) return;
-                        setMoveState({
-                          index,
-                          offset: e.clientY - moveState.startPointY,
-                          startPointY: moveState.startPointY,
-                        });
-                      },
-                      end: () => {
-                        if (moveState != null) {
-                          const offsetCount = Math.round(
-                            moveState.offset / height
-                          );
-                          editHandler.onMove(
-                            timeline.timelineOwner,
-                            timeline.timelineName,
-                            moveState.index,
-                            offsetCount
-                          );
-                        }
-                        setMoveState(null);
-                      },
-                    },
-                  }
-                : undefined
-            }
-          />
-        );
-      })}
-    </>
-  );
-};
-
-interface TimelineBoardUIProps {
-  title?: string | null;
-  state: "offline" | "loading" | "loaded";
-  timelines: TimelineBookmark[];
-  onReload: () => void;
-  className?: string;
-  editHandler?: {
-    onMove: (
-      owner: string,
-      timeline: string,
-      index: number,
-      offset: number
-    ) => void;
-    onDelete: (owner: string, timeline: string) => void;
-  };
-}
-
-const TimelineBoardUI: React.FC<TimelineBoardUIProps> = (props) => {
-  const { title, state, timelines, className, editHandler } = props;
-
-  const editable = editHandler != null;
-
-  const [editing, setEditing] = React.useState<boolean>(false);
-
-  return (
-    <Card className={classnames("timeline-board", className)}>
-      <div className="timeline-board-header">
-        {title != null && <h3>{title}</h3>}
-        {editable &&
-          (editing ? (
-            <FlatButton
-              text="done"
-              onClick={() => {
-                setEditing(false);
-              }}
-            />
-          ) : (
-            <FlatButton
-              text="edit"
-              onClick={() => {
-                setEditing(true);
-              }}
-            />
-          ))}
-      </div>
-      {(() => {
-        if (state === "loading") {
-          return (
-            <div className="d-flex flex-grow-1 justify-content-center align-items-center">
-              <Spinner />
-            </div>
-          );
-        } else if (state === "offline") {
-          return (
-            <div className="d-flex flex-grow-1 justify-content-center align-items-center">
-              <LoadFailReload onReload={props.onReload} />
-            </div>
-          );
-        } else {
-          return (
-            <TimelineBoardItemContainer
-              timelines={timelines}
-              editHandler={
-                editHandler && editing
-                  ? {
-                      onDelete: editHandler.onDelete,
-                      onMove: (owner, timeline, index, offset) => {
-                        if (index + offset >= timelines.length) {
-                          offset = timelines.length - index - 1;
-                        } else if (index + offset < 0) {
-                          offset = -index;
-                        }
-                        editHandler.onMove(owner, timeline, index, offset);
-                      },
-                    }
-                  : undefined
-              }
-            />
-          );
-        }
-      })()}
-    </Card>
-  );
-};
-
-export interface TimelineBoardProps {
-  title?: string | null;
-  className?: string;
-  load: () => Promise<TimelineBookmark[]>;
-  editHandler?: {
-    onMove: (
-      owner: string,
-      timeline: string,
-      index: number,
-      offset: number
-    ) => Promise<void>;
-    onDelete: (owner: string, timeline: string) => Promise<void>;
-  };
-}
-
-const TimelineBoard: React.FC<TimelineBoardProps> = ({
-  className,
-  title,
-  load,
-  editHandler,
-}) => {
-  const [state, setState] = React.useState<"offline" | "loading" | "loaded">(
-    "loading"
-  );
-  const [timelines, setTimelines] = React.useState<TimelineBookmark[]>([]);
-
-  React.useEffect(() => {
-    let subscribe = true;
-    if (state === "loading") {
-      void load().then(
-        (timelines) => {
-          if (subscribe) {
-            setState("loaded");
-            setTimelines(timelines);
-          }
-        },
-        () => {
-          setState("offline");
-        }
-      );
-    }
-    return () => {
-      subscribe = false;
-    };
-  }, [load, state]);
-
-  return (
-    <TimelineBoardUI
-      title={title}
-      className={className}
-      state={state}
-      timelines={timelines}
-      onReload={() => {
-        setState("loaded");
-      }}
-      editHandler={
-        typeof timelines === "object" && editHandler != null
-          ? {
-              onMove: (owner, timeline, index, offset) => {
-                const newTimelines = timelines.slice();
-                const [t] = newTimelines.splice(index, 1);
-                newTimelines.splice(index + offset, 0, t);
-                setTimelines(newTimelines);
-                editHandler
-                  .onMove(owner, timeline, index, offset)
-                  .then(null, () => {
-                    setTimelines(timelines);
-                  });
-              },
-              onDelete: (owner, timeline) => {
-                const newTimelines = timelines.slice();
-                newTimelines.splice(
-                  timelines.findIndex(
-                    (t) =>
-                      t.timelineOwner === owner && t.timelineName === timeline
-                  ),
-                  1
-                );
-                setTimelines(newTimelines);
-                editHandler.onDelete(owner, timeline).then(null, () => {
-                  setTimelines(timelines);
-                });
-              },
-            }
-          : undefined
-      }
-    />
-  );
-};
-
-export default TimelineBoard;
diff --git a/FrontEnd/src/views/center/TimelineCreateDialog.tsx b/FrontEnd/src/views/center/TimelineCreateDialog.tsx
deleted file mode 100644
index 63742936..00000000
--- a/FrontEnd/src/views/center/TimelineCreateDialog.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-import * as React from "react";
-import { useNavigate } from "react-router-dom";
-
-import { validateTimelineName } from "@/services/timeline";
-import { getHttpTimelineClient, HttpTimelineInfo } from "@/http/timeline";
-
-import OperationDialog from "../common/dialog/OperationDialog";
-import { useUserLoggedIn } from "@/services/user";
-
-interface TimelineCreateDialogProps {
-  open: boolean;
-  close: () => void;
-}
-
-const TimelineCreateDialog: React.FC<TimelineCreateDialogProps> = (props) => {
-  const navigate = useNavigate();
-
-  const user = useUserLoggedIn();
-
-  return (
-    <OperationDialog
-      open={props.open}
-      onClose={props.close}
-      themeColor="success"
-      title="home.createDialog.title"
-      inputScheme={
-        [
-          {
-            type: "text",
-            label: "home.createDialog.name",
-            helperText: "home.createDialog.nameFormat",
-          },
-        ] as const
-      }
-      inputValidator={([name]) => {
-        if (name.length === 0) {
-          return { 0: "home.createDialog.noEmpty" };
-        } else if (name.length > 26) {
-          return { 0: "home.createDialog.tooLong" };
-        } else if (!validateTimelineName(name)) {
-          return { 0: "home.createDialog.badFormat" };
-        } else {
-          return null;
-        }
-      }}
-      onProcess={([name]): Promise<HttpTimelineInfo> =>
-        getHttpTimelineClient().postTimeline({ name })
-      }
-      onSuccessAndClose={(timeline: HttpTimelineInfo) => {
-        navigate(`${user.username}/${timeline.nameV2}`);
-      }}
-      failurePrompt={(e) => `${e as string}`}
-    />
-  );
-};
-
-export default TimelineCreateDialog;
diff --git a/FrontEnd/src/views/center/index.css b/FrontEnd/src/views/center/index.css
deleted file mode 100644
index a779ff90..00000000
--- a/FrontEnd/src/views/center/index.css
+++ /dev/null
@@ -1,43 +0,0 @@
-.timeline-board {
-  min-height: 200px;
-  height: 100%;
-  position: relative;
-  padding: 1em 0;
-  display: flex;
-  flex-direction: column;
-}
-
-.timeline-board-header {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  padding: 0 1em;
-}
-
-.timeline-board-item {
-  font-size: 1.1em;
-  height: 48px;
-  transition: background 0.3s;
-  display: flex;
-  align-items: center;
-  padding: 0 1em;
-}
-
-.timeline-board-item .icon {
-  height: 1.3em;
-  color: black;
-}
-
-.timeline-board-item:hover {
-  background: #dee2e6;
-}
-.timeline-board-item .right {
-  display: flex;
-  align-items: center;
-  flex-shrink: 0;
-}
-.timeline-board-item .title {
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-}
diff --git a/FrontEnd/src/views/center/index.tsx b/FrontEnd/src/views/center/index.tsx
deleted file mode 100644
index 77af2c20..00000000
--- a/FrontEnd/src/views/center/index.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-import * as React from "react";
-import { useNavigate } from "react-router-dom";
-
-import { useUserLoggedIn } from "@/services/user";
-
-import SearchInput from "../common/SearchInput";
-import Button from "../common/button/Button";
-import CenterBoards from "./CenterBoards";
-import TimelineCreateDialog from "./TimelineCreateDialog";
-
-import "./index.css";
-
-const HomePage: React.FC = () => {
-  const navigate = useNavigate();
-
-  const user = useUserLoggedIn();
-
-  const [navText, setNavText] = React.useState<string>("");
-
-  const [dialog, setDialog] = React.useState<"create" | null>(null);
-
-  return (
-    <>
-      <div className="container">
-        <div className="row my-3 justify-content-center">
-          <div className="col col-12 col-md-8">
-            <SearchInput
-              className="justify-content-center"
-              value={navText}
-              onChange={setNavText}
-              onButtonClick={() => {
-                navigate(`search?q=${navText}`);
-              }}
-              additionalButton={
-                user != null && (
-                  <Button
-                    text="home.createButton"
-                    color="success"
-                    onClick={() => {
-                      setDialog("create");
-                    }}
-                  />
-                )
-              }
-            />
-          </div>
-        </div>
-        <CenterBoards />
-      </div>
-      <TimelineCreateDialog
-        open={dialog === "create"}
-        close={() => {
-          setDialog(null);
-        }}
-      />
-    </>
-  );
-};
-
-export default HomePage;
diff --git a/FrontEnd/src/views/common/button/LoadingButton.css b/FrontEnd/src/views/common/button/LoadingButton.css
index 2f73116a..0a7e4a3a 100644
--- a/FrontEnd/src/views/common/button/LoadingButton.css
+++ b/FrontEnd/src/views/common/button/LoadingButton.css
@@ -11,4 +11,4 @@
 
 .cru-loading-button-spinner {
   margin-left: 0.5em;
-}
+}
\ No newline at end of file
diff --git a/FrontEnd/src/views/search/index.css b/FrontEnd/src/views/search/index.css
deleted file mode 100644
index 6ff4d9fa..00000000
--- a/FrontEnd/src/views/search/index.css
+++ /dev/null
@@ -1,15 +0,0 @@
-.timeline-search-result-item {
-  border: 1px solid;
-  border-color: #e9ecef;
-  background: #f8f9fa;
-  transition: all 0.3s;
-}
-.timeline-search-result-item:hover {
-  border-color: #0d6efd;
-}
-
-.timeline-search-result-item-avatar {
-  width: 2em;
-  height: 2em;
-  border-radius: 50%;
-}
diff --git a/FrontEnd/src/views/search/index.tsx b/FrontEnd/src/views/search/index.tsx
deleted file mode 100644
index 58257465..00000000
--- a/FrontEnd/src/views/search/index.tsx
+++ /dev/null
@@ -1,131 +0,0 @@
-import * as React from "react";
-import { useTranslation } from "react-i18next";
-import { useNavigate, useLocation } from "react-router-dom";
-import { Link } from "react-router-dom";
-
-import { HttpNetworkError } from "@/http/common";
-import { getHttpSearchClient } from "@/http/search";
-import { HttpTimelineInfo } from "@/http/timeline";
-
-import SearchInput from "../common/SearchInput";
-import UserAvatar from "../common/user/UserAvatar";
-
-import "./index.css";
-
-const TimelineSearchResultItemView: React.FC<{
-  timeline: HttpTimelineInfo;
-}> = ({ timeline }) => {
-  return (
-    <div className="timeline-search-result-item my-2 p-3">
-      <h4>
-        <Link
-          to={`/${timeline.owner.username}/${timeline.nameV2}`}
-          className="mb-2 text-primary"
-        >
-          {timeline.title}
-          <small className="ms-3 text-secondary">{timeline.nameV2}</small>
-        </Link>
-      </h4>
-      <div>
-        <UserAvatar
-          username={timeline.owner.username}
-          className="timeline-search-result-item-avatar me-2"
-        />
-        {timeline.owner.nickname}
-        <small className="ms-3 text-secondary">
-          @{timeline.owner.username}
-        </small>
-      </div>
-    </div>
-  );
-};
-
-const SearchPage: React.FC = () => {
-  const { t } = useTranslation();
-
-  const navigate = useNavigate();
-  const location = useLocation();
-  const searchParams = new URLSearchParams(location.search);
-  const queryParam = searchParams.get("q");
-
-  const [searchText, setSearchText] = React.useState<string>("");
-  const [state, setState] = React.useState<
-    HttpTimelineInfo[] | "init" | "loading" | "network-error" | "error"
-  >("init");
-
-  const [forceResearchKey, setForceResearchKey] = React.useState<number>(0);
-
-  React.useEffect(() => {
-    setState("init");
-    if (queryParam != null && queryParam.length > 0) {
-      setSearchText(queryParam);
-      setState("loading");
-      void getHttpSearchClient()
-        .searchTimelines(queryParam)
-        .then(
-          (ts) => {
-            setState(ts);
-          },
-          (e) => {
-            if (e instanceof HttpNetworkError) {
-              setState("network-error");
-            } else {
-              setState("error");
-            }
-          }
-        );
-    }
-  }, [queryParam, forceResearchKey]);
-
-  return (
-    <div className="container my-3">
-      <div className="row justify-content-center">
-        <SearchInput
-          className="col-12 col-sm-9 col-md-6"
-          value={searchText}
-          onChange={setSearchText}
-          loading={state === "loading"}
-          onButtonClick={() => {
-            if (queryParam === searchText) {
-              setForceResearchKey((old) => old + 1);
-            } else {
-              navigate(`/search?q=${searchText}`);
-            }
-          }}
-        />
-      </div>
-      {(() => {
-        switch (state) {
-          case "init": {
-            if (queryParam == null || queryParam.length === 0) {
-              return <div>{t("searchPage.input")}</div>;
-            }
-            break;
-          }
-          case "loading": {
-            return <div>{t("searchPage.loading")}</div>;
-          }
-          case "network-error": {
-            return <div className="text-danger">{t("error.network")}</div>;
-          }
-          case "error": {
-            return <div className="text-danger">{t("error.unknown")}</div>;
-          }
-          default: {
-            if (state.length === 0) {
-              return <div>{t("searchPage.noResult")}</div>;
-            }
-            return state.map((t) => (
-              <TimelineSearchResultItemView
-                key={`${t.owner.username}/${t.nameV2}`}
-                timeline={t}
-              />
-            ));
-          }
-        }
-      })()}
-    </div>
-  );
-};
-
-export default SearchPage;
diff --git a/FrontEnd/tsconfig.json b/FrontEnd/tsconfig.json
index 97946126..cd6aa25d 100644
--- a/FrontEnd/tsconfig.json
+++ b/FrontEnd/tsconfig.json
@@ -29,5 +29,8 @@
   },
   "include": [
     "src"
+  ],
+  "exclude": [
+    "src/migrating"
   ]
 }
\ No newline at end of file
-- 
cgit v1.2.3


From f5dfd52f6efece2f4cad227044ecf4dd66301bbc Mon Sep 17 00:00:00 2001
From: crupest <crupest@outlook.com>
Date: Sat, 26 Aug 2023 21:36:58 +0800
Subject: ...

---
 FrontEnd/src/App.tsx                               |   4 +-
 FrontEnd/src/components/AppBar.css                 |  87 ++++
 FrontEnd/src/components/AppBar.tsx                 |  98 +++++
 FrontEnd/src/components/BlobImage.tsx              |  26 ++
 FrontEnd/src/components/Card.css                   |  20 +
 FrontEnd/src/components/Card.tsx                   |  38 ++
 FrontEnd/src/components/Icon.css                   |   3 +
 FrontEnd/src/components/Icon.tsx                   |  30 ++
 FrontEnd/src/components/ImageCropper.css           |  38 ++
 FrontEnd/src/components/ImageCropper.tsx           | 312 ++++++++++++++
 FrontEnd/src/components/LoadFailReload.tsx         |  37 ++
 FrontEnd/src/components/LoadingPage.tsx            |  13 +
 FrontEnd/src/components/Page.tsx                   |  15 +
 FrontEnd/src/components/SearchInput.css            |   8 +
 FrontEnd/src/components/SearchInput.tsx            |  50 +++
 FrontEnd/src/components/Skeleton.css               |  14 +
 FrontEnd/src/components/Skeleton.tsx               |  32 ++
 FrontEnd/src/components/Spinner.css                |  13 +
 FrontEnd/src/components/Spinner.tsx                |  36 ++
 FrontEnd/src/components/TimelineLogo.tsx           |  27 ++
 FrontEnd/src/components/alert/AlertHost.tsx        | 113 +++++
 FrontEnd/src/components/alert/alert.css            |  33 ++
 FrontEnd/src/components/breakpoints.ts             |   3 +
 FrontEnd/src/components/button/Button.css          |  64 +++
 FrontEnd/src/components/button/Button.tsx          |  46 ++
 FrontEnd/src/components/button/ButtonRow.css       |   0
 FrontEnd/src/components/button/ButtonRow.tsx       |  62 +++
 FrontEnd/src/components/button/ButtonRowV2.tsx     | 143 +++++++
 FrontEnd/src/components/button/FlatButton.css      |  27 ++
 FrontEnd/src/components/button/FlatButton.tsx      |  36 ++
 FrontEnd/src/components/button/IconButton.css      |  30 ++
 FrontEnd/src/components/button/IconButton.tsx      |  30 ++
 FrontEnd/src/components/button/LoadingButton.css   |  13 +
 FrontEnd/src/components/button/LoadingButton.tsx   |  40 ++
 FrontEnd/src/components/button/index.tsx           |  15 +
 FrontEnd/src/components/common.ts                  |  14 +
 FrontEnd/src/components/dialog/ConfirmDialog.css   |   0
 FrontEnd/src/components/dialog/ConfirmDialog.tsx   |  59 +++
 FrontEnd/src/components/dialog/Dialog.css          |  60 +++
 FrontEnd/src/components/dialog/Dialog.tsx          |  63 +++
 FrontEnd/src/components/dialog/DialogContainer.css |  20 +
 FrontEnd/src/components/dialog/DialogContainer.tsx |  95 +++++
 FrontEnd/src/components/dialog/FullPageDialog.css  |  44 ++
 FrontEnd/src/components/dialog/FullPageDialog.tsx  |  53 +++
 FrontEnd/src/components/dialog/OperationDialog.css |   8 +
 FrontEnd/src/components/dialog/OperationDialog.tsx | 230 ++++++++++
 FrontEnd/src/components/dialog/index.ts            |  64 +++
 FrontEnd/src/components/hooks.ts                   |  14 +
 FrontEnd/src/components/index.css                  | 100 +++++
 FrontEnd/src/components/input/InputGroup.css       |  54 +++
 FrontEnd/src/components/input/InputGroup.tsx       | 463 +++++++++++++++++++++
 FrontEnd/src/components/input/index.ts             |  11 +
 FrontEnd/src/components/list/ListContainer.css     |   4 +
 FrontEnd/src/components/list/ListContainer.tsx     |  23 +
 FrontEnd/src/components/list/ListItemContainer.css |   3 +
 FrontEnd/src/components/list/ListItemContainer.tsx |  23 +
 FrontEnd/src/components/list/index.ts              |   4 +
 FrontEnd/src/components/menu/Menu.css              |  36 ++
 FrontEnd/src/components/menu/Menu.tsx              |  62 +++
 FrontEnd/src/components/menu/PopupMenu.css         |   7 +
 FrontEnd/src/components/menu/PopupMenu.tsx         |  73 ++++
 FrontEnd/src/components/tab/TabPages.tsx           |  71 ++++
 FrontEnd/src/components/tab/Tabs.css               |  33 ++
 FrontEnd/src/components/tab/Tabs.tsx               |  62 +++
 FrontEnd/src/components/theme-color.css            | 173 ++++++++
 FrontEnd/src/components/theme.css                  | 146 +++++++
 FrontEnd/src/components/user/UserAvatar.tsx        |  22 +
 FrontEnd/src/http/bookmark.ts                      |   2 +-
 FrontEnd/src/http/timeline.ts                      |   2 +-
 FrontEnd/src/migrating/center/CenterBoards.tsx     |  10 +-
 FrontEnd/src/migrating/center/TimelineBoard.tsx    |   2 +-
 .../src/migrating/center/TimelineCreateDialog.tsx  |   6 +-
 FrontEnd/src/migrating/center/index.tsx            |   2 +-
 FrontEnd/src/pages/about/index.tsx                 |   4 +-
 FrontEnd/src/pages/loading/index.tsx               |   2 +-
 FrontEnd/src/pages/login/index.tsx                 |  10 +-
 FrontEnd/src/pages/register/index.tsx              |  10 +-
 FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx  |  16 +-
 .../src/pages/setting/ChangeNicknameDialog.tsx     |   8 +-
 .../src/pages/setting/ChangePasswordDialog.tsx     |   9 +-
 FrontEnd/src/pages/setting/index.tsx               |  18 +-
 FrontEnd/src/pages/timeline/CollapseButton.tsx     |   2 +-
 FrontEnd/src/pages/timeline/MarkdownPostEdit.tsx   |  14 +-
 FrontEnd/src/pages/timeline/Timeline.tsx           |   8 +-
 FrontEnd/src/pages/timeline/TimelineCard.tsx       |  22 +-
 .../src/pages/timeline/TimelineDeleteDialog.tsx    |   4 +-
 FrontEnd/src/pages/timeline/TimelineMember.tsx     |  16 +-
 FrontEnd/src/pages/timeline/TimelinePostCard.tsx   |   2 +-
 .../src/pages/timeline/TimelinePostContentView.tsx |  12 +-
 .../src/pages/timeline/TimelinePostCreateView.tsx  |  16 +-
 FrontEnd/src/pages/timeline/TimelinePostList.tsx   |   2 +-
 FrontEnd/src/pages/timeline/TimelinePostView.tsx   |  16 +-
 .../timeline/TimelinePropertyChangeDialog.tsx      |  10 +-
 FrontEnd/src/pages/timeline/index.tsx              |   2 +-
 FrontEnd/src/services/TimelinePostBuilder.ts       |   6 +-
 FrontEnd/src/services/alert.ts                     |   4 +-
 FrontEnd/src/services/timeline.ts                  |   4 +-
 FrontEnd/src/services/user.ts                      |   8 +-
 FrontEnd/src/views/common/AppBar.css               |  87 ----
 FrontEnd/src/views/common/AppBar.tsx               |  98 -----
 FrontEnd/src/views/common/BlobImage.tsx            |  26 --
 FrontEnd/src/views/common/Card.css                 |  20 -
 FrontEnd/src/views/common/Card.tsx                 |  38 --
 FrontEnd/src/views/common/Icon.css                 |   3 -
 FrontEnd/src/views/common/Icon.tsx                 |  30 --
 FrontEnd/src/views/common/ImageCropper.css         |  38 --
 FrontEnd/src/views/common/ImageCropper.tsx         | 312 --------------
 FrontEnd/src/views/common/LoadFailReload.tsx       |  37 --
 FrontEnd/src/views/common/LoadingPage.tsx          |  13 -
 FrontEnd/src/views/common/Page.tsx                 |  15 -
 FrontEnd/src/views/common/SearchInput.css          |   8 -
 FrontEnd/src/views/common/SearchInput.tsx          |  50 ---
 FrontEnd/src/views/common/Skeleton.css             |  14 -
 FrontEnd/src/views/common/Skeleton.tsx             |  32 --
 FrontEnd/src/views/common/Spinner.css              |  13 -
 FrontEnd/src/views/common/Spinner.tsx              |  36 --
 FrontEnd/src/views/common/TimelineLogo.tsx         |  27 --
 FrontEnd/src/views/common/alert/AlertHost.tsx      | 113 -----
 FrontEnd/src/views/common/alert/alert.css          |  33 --
 FrontEnd/src/views/common/breakpoints.ts           |   3 -
 FrontEnd/src/views/common/button/Button.css        |  64 ---
 FrontEnd/src/views/common/button/Button.tsx        |  46 --
 FrontEnd/src/views/common/button/ButtonRow.css     |   0
 FrontEnd/src/views/common/button/ButtonRow.tsx     |  62 ---
 FrontEnd/src/views/common/button/ButtonRowV2.tsx   | 143 -------
 FrontEnd/src/views/common/button/FlatButton.css    |  27 --
 FrontEnd/src/views/common/button/FlatButton.tsx    |  36 --
 FrontEnd/src/views/common/button/IconButton.css    |  30 --
 FrontEnd/src/views/common/button/IconButton.tsx    |  30 --
 FrontEnd/src/views/common/button/LoadingButton.css |  13 -
 FrontEnd/src/views/common/button/LoadingButton.tsx |  40 --
 FrontEnd/src/views/common/button/index.tsx         |  15 -
 FrontEnd/src/views/common/common.ts                |  14 -
 FrontEnd/src/views/common/dialog/ConfirmDialog.css |   0
 FrontEnd/src/views/common/dialog/ConfirmDialog.tsx |  59 ---
 FrontEnd/src/views/common/dialog/Dialog.css        |  60 ---
 FrontEnd/src/views/common/dialog/Dialog.tsx        |  63 ---
 .../src/views/common/dialog/DialogContainer.css    |  20 -
 .../src/views/common/dialog/DialogContainer.tsx    |  95 -----
 .../src/views/common/dialog/FullPageDialog.css     |  44 --
 .../src/views/common/dialog/FullPageDialog.tsx     |  53 ---
 .../src/views/common/dialog/OperationDialog.css    |   8 -
 .../src/views/common/dialog/OperationDialog.tsx    | 228 ----------
 FrontEnd/src/views/common/dialog/index.ts          |  64 ---
 FrontEnd/src/views/common/hooks.ts                 |  14 -
 FrontEnd/src/views/common/index.css                | 100 -----
 FrontEnd/src/views/common/input/InputGroup.css     |  54 ---
 FrontEnd/src/views/common/input/InputGroup.tsx     | 461 --------------------
 FrontEnd/src/views/common/list/ListContainer.css   |   4 -
 FrontEnd/src/views/common/list/ListContainer.tsx   |  23 -
 .../src/views/common/list/ListItemContainer.css    |   3 -
 .../src/views/common/list/ListItemContainer.tsx    |  23 -
 FrontEnd/src/views/common/list/index.ts            |   4 -
 FrontEnd/src/views/common/menu/Menu.css            |  36 --
 FrontEnd/src/views/common/menu/Menu.tsx            |  62 ---
 FrontEnd/src/views/common/menu/PopupMenu.css       |   7 -
 FrontEnd/src/views/common/menu/PopupMenu.tsx       |  73 ----
 FrontEnd/src/views/common/tab/TabPages.tsx         |  71 ----
 FrontEnd/src/views/common/tab/Tabs.css             |  33 --
 FrontEnd/src/views/common/tab/Tabs.tsx             |  62 ---
 FrontEnd/src/views/common/theme-color.css          | 173 --------
 FrontEnd/src/views/common/theme.css                | 146 -------
 FrontEnd/src/views/common/user/UserAvatar.tsx      |  22 -
 FrontEnd/tsconfig.json                             |   3 -
 164 files changed, 3771 insertions(+), 3762 deletions(-)
 create mode 100644 FrontEnd/src/components/AppBar.css
 create mode 100644 FrontEnd/src/components/AppBar.tsx
 create mode 100644 FrontEnd/src/components/BlobImage.tsx
 create mode 100644 FrontEnd/src/components/Card.css
 create mode 100644 FrontEnd/src/components/Card.tsx
 create mode 100644 FrontEnd/src/components/Icon.css
 create mode 100644 FrontEnd/src/components/Icon.tsx
 create mode 100644 FrontEnd/src/components/ImageCropper.css
 create mode 100644 FrontEnd/src/components/ImageCropper.tsx
 create mode 100644 FrontEnd/src/components/LoadFailReload.tsx
 create mode 100644 FrontEnd/src/components/LoadingPage.tsx
 create mode 100644 FrontEnd/src/components/Page.tsx
 create mode 100644 FrontEnd/src/components/SearchInput.css
 create mode 100644 FrontEnd/src/components/SearchInput.tsx
 create mode 100644 FrontEnd/src/components/Skeleton.css
 create mode 100644 FrontEnd/src/components/Skeleton.tsx
 create mode 100644 FrontEnd/src/components/Spinner.css
 create mode 100644 FrontEnd/src/components/Spinner.tsx
 create mode 100644 FrontEnd/src/components/TimelineLogo.tsx
 create mode 100644 FrontEnd/src/components/alert/AlertHost.tsx
 create mode 100644 FrontEnd/src/components/alert/alert.css
 create mode 100644 FrontEnd/src/components/breakpoints.ts
 create mode 100644 FrontEnd/src/components/button/Button.css
 create mode 100644 FrontEnd/src/components/button/Button.tsx
 create mode 100644 FrontEnd/src/components/button/ButtonRow.css
 create mode 100644 FrontEnd/src/components/button/ButtonRow.tsx
 create mode 100644 FrontEnd/src/components/button/ButtonRowV2.tsx
 create mode 100644 FrontEnd/src/components/button/FlatButton.css
 create mode 100644 FrontEnd/src/components/button/FlatButton.tsx
 create mode 100644 FrontEnd/src/components/button/IconButton.css
 create mode 100644 FrontEnd/src/components/button/IconButton.tsx
 create mode 100644 FrontEnd/src/components/button/LoadingButton.css
 create mode 100644 FrontEnd/src/components/button/LoadingButton.tsx
 create mode 100644 FrontEnd/src/components/button/index.tsx
 create mode 100644 FrontEnd/src/components/common.ts
 create mode 100644 FrontEnd/src/components/dialog/ConfirmDialog.css
 create mode 100644 FrontEnd/src/components/dialog/ConfirmDialog.tsx
 create mode 100644 FrontEnd/src/components/dialog/Dialog.css
 create mode 100644 FrontEnd/src/components/dialog/Dialog.tsx
 create mode 100644 FrontEnd/src/components/dialog/DialogContainer.css
 create mode 100644 FrontEnd/src/components/dialog/DialogContainer.tsx
 create mode 100644 FrontEnd/src/components/dialog/FullPageDialog.css
 create mode 100644 FrontEnd/src/components/dialog/FullPageDialog.tsx
 create mode 100644 FrontEnd/src/components/dialog/OperationDialog.css
 create mode 100644 FrontEnd/src/components/dialog/OperationDialog.tsx
 create mode 100644 FrontEnd/src/components/dialog/index.ts
 create mode 100644 FrontEnd/src/components/hooks.ts
 create mode 100644 FrontEnd/src/components/index.css
 create mode 100644 FrontEnd/src/components/input/InputGroup.css
 create mode 100644 FrontEnd/src/components/input/InputGroup.tsx
 create mode 100644 FrontEnd/src/components/input/index.ts
 create mode 100644 FrontEnd/src/components/list/ListContainer.css
 create mode 100644 FrontEnd/src/components/list/ListContainer.tsx
 create mode 100644 FrontEnd/src/components/list/ListItemContainer.css
 create mode 100644 FrontEnd/src/components/list/ListItemContainer.tsx
 create mode 100644 FrontEnd/src/components/list/index.ts
 create mode 100644 FrontEnd/src/components/menu/Menu.css
 create mode 100644 FrontEnd/src/components/menu/Menu.tsx
 create mode 100644 FrontEnd/src/components/menu/PopupMenu.css
 create mode 100644 FrontEnd/src/components/menu/PopupMenu.tsx
 create mode 100644 FrontEnd/src/components/tab/TabPages.tsx
 create mode 100644 FrontEnd/src/components/tab/Tabs.css
 create mode 100644 FrontEnd/src/components/tab/Tabs.tsx
 create mode 100644 FrontEnd/src/components/theme-color.css
 create mode 100644 FrontEnd/src/components/theme.css
 create mode 100644 FrontEnd/src/components/user/UserAvatar.tsx
 delete mode 100644 FrontEnd/src/views/common/AppBar.css
 delete mode 100644 FrontEnd/src/views/common/AppBar.tsx
 delete mode 100644 FrontEnd/src/views/common/BlobImage.tsx
 delete mode 100644 FrontEnd/src/views/common/Card.css
 delete mode 100644 FrontEnd/src/views/common/Card.tsx
 delete mode 100644 FrontEnd/src/views/common/Icon.css
 delete mode 100644 FrontEnd/src/views/common/Icon.tsx
 delete mode 100644 FrontEnd/src/views/common/ImageCropper.css
 delete mode 100644 FrontEnd/src/views/common/ImageCropper.tsx
 delete mode 100644 FrontEnd/src/views/common/LoadFailReload.tsx
 delete mode 100644 FrontEnd/src/views/common/LoadingPage.tsx
 delete mode 100644 FrontEnd/src/views/common/Page.tsx
 delete mode 100644 FrontEnd/src/views/common/SearchInput.css
 delete mode 100644 FrontEnd/src/views/common/SearchInput.tsx
 delete mode 100644 FrontEnd/src/views/common/Skeleton.css
 delete mode 100644 FrontEnd/src/views/common/Skeleton.tsx
 delete mode 100644 FrontEnd/src/views/common/Spinner.css
 delete mode 100644 FrontEnd/src/views/common/Spinner.tsx
 delete mode 100644 FrontEnd/src/views/common/TimelineLogo.tsx
 delete mode 100644 FrontEnd/src/views/common/alert/AlertHost.tsx
 delete mode 100644 FrontEnd/src/views/common/alert/alert.css
 delete mode 100644 FrontEnd/src/views/common/breakpoints.ts
 delete mode 100644 FrontEnd/src/views/common/button/Button.css
 delete mode 100644 FrontEnd/src/views/common/button/Button.tsx
 delete mode 100644 FrontEnd/src/views/common/button/ButtonRow.css
 delete mode 100644 FrontEnd/src/views/common/button/ButtonRow.tsx
 delete mode 100644 FrontEnd/src/views/common/button/ButtonRowV2.tsx
 delete mode 100644 FrontEnd/src/views/common/button/FlatButton.css
 delete mode 100644 FrontEnd/src/views/common/button/FlatButton.tsx
 delete mode 100644 FrontEnd/src/views/common/button/IconButton.css
 delete mode 100644 FrontEnd/src/views/common/button/IconButton.tsx
 delete mode 100644 FrontEnd/src/views/common/button/LoadingButton.css
 delete mode 100644 FrontEnd/src/views/common/button/LoadingButton.tsx
 delete mode 100644 FrontEnd/src/views/common/button/index.tsx
 delete mode 100644 FrontEnd/src/views/common/common.ts
 delete mode 100644 FrontEnd/src/views/common/dialog/ConfirmDialog.css
 delete mode 100644 FrontEnd/src/views/common/dialog/ConfirmDialog.tsx
 delete mode 100644 FrontEnd/src/views/common/dialog/Dialog.css
 delete mode 100644 FrontEnd/src/views/common/dialog/Dialog.tsx
 delete mode 100644 FrontEnd/src/views/common/dialog/DialogContainer.css
 delete mode 100644 FrontEnd/src/views/common/dialog/DialogContainer.tsx
 delete mode 100644 FrontEnd/src/views/common/dialog/FullPageDialog.css
 delete mode 100644 FrontEnd/src/views/common/dialog/FullPageDialog.tsx
 delete mode 100644 FrontEnd/src/views/common/dialog/OperationDialog.css
 delete mode 100644 FrontEnd/src/views/common/dialog/OperationDialog.tsx
 delete mode 100644 FrontEnd/src/views/common/dialog/index.ts
 delete mode 100644 FrontEnd/src/views/common/hooks.ts
 delete mode 100644 FrontEnd/src/views/common/index.css
 delete mode 100644 FrontEnd/src/views/common/input/InputGroup.css
 delete mode 100644 FrontEnd/src/views/common/input/InputGroup.tsx
 delete mode 100644 FrontEnd/src/views/common/list/ListContainer.css
 delete mode 100644 FrontEnd/src/views/common/list/ListContainer.tsx
 delete mode 100644 FrontEnd/src/views/common/list/ListItemContainer.css
 delete mode 100644 FrontEnd/src/views/common/list/ListItemContainer.tsx
 delete mode 100644 FrontEnd/src/views/common/list/index.ts
 delete mode 100644 FrontEnd/src/views/common/menu/Menu.css
 delete mode 100644 FrontEnd/src/views/common/menu/Menu.tsx
 delete mode 100644 FrontEnd/src/views/common/menu/PopupMenu.css
 delete mode 100644 FrontEnd/src/views/common/menu/PopupMenu.tsx
 delete mode 100644 FrontEnd/src/views/common/tab/TabPages.tsx
 delete mode 100644 FrontEnd/src/views/common/tab/Tabs.css
 delete mode 100644 FrontEnd/src/views/common/tab/Tabs.tsx
 delete mode 100644 FrontEnd/src/views/common/theme-color.css
 delete mode 100644 FrontEnd/src/views/common/theme.css
 delete mode 100644 FrontEnd/src/views/common/user/UserAvatar.tsx

(limited to 'FrontEnd/src/App.tsx')

diff --git a/FrontEnd/src/App.tsx b/FrontEnd/src/App.tsx
index 6029daac..b5b076ec 100644
--- a/FrontEnd/src/App.tsx
+++ b/FrontEnd/src/App.tsx
@@ -1,7 +1,7 @@
 import { Suspense } from "react";
 import { BrowserRouter, Route, Routes } from "react-router-dom";
 
-import AppBar from "./views/common/AppBar";
+import AppBar from "./components/AppBar";
 import NotFoundPage from "./pages/404";
 import HomePage from "./pages/home";
 import AboutPage from "./pages/about";
@@ -10,7 +10,7 @@ import LoginPage from "./pages/login";
 import RegisterPage from "./pages/register";
 import TimelinePage from "./pages/timeline";
 import LoadingPage from "./pages/loading";
-import AlertHost from "./views/common/alert/AlertHost";
+import AlertHost from "./components/alert/AlertHost";
 
 export default function App() {
   return (
diff --git a/FrontEnd/src/components/AppBar.css b/FrontEnd/src/components/AppBar.css
new file mode 100644
index 00000000..a0d975b5
--- /dev/null
+++ b/FrontEnd/src/components/AppBar.css
@@ -0,0 +1,87 @@
+.app-bar {
+  height: 56px;
+  position: fixed;
+  z-index: 1030;
+  top: 0;
+  left: 0;
+  right: 0;
+  background-color: var(--cru-primary-color);
+}
+
+.app-bar {
+  display: flex;
+}
+
+.app-bar .app-bar-brand {
+  display: flex;
+  align-items: center;
+}
+
+.app-bar .app-bar-brand-icon {
+  height: 2em;
+}
+
+.app-bar .app-bar-user-area {
+  display: flex;
+  margin-left: auto;
+}
+
+.app-bar a {
+  background-color: var(--cru-primary-color);
+  color: var(--cru-push-button-text-color);
+  text-decoration: none;
+  display: flex;
+  align-items: center;
+  padding: 0 1em;
+  transition: all 0.5s;
+}
+
+.app-bar a:hover {
+  background-color: var(--cru-clickable-primary-hover-color);
+}
+
+.app-bar a:focus {
+  background-color: var(--cru-clickable-primary-focus-color);
+}
+
+.app-bar a:active {
+  background-color: var(--cru-clickable-primary-active-color);
+}
+
+/* the current page */
+.app-bar a.active {
+  background-color: var(--cru-clickable-primary-focus-color);
+}
+
+.app-bar .app-bar-avatar img {
+  width: 45px;
+  height: 45px;
+  background-color: white;
+  border-radius: 50%;
+}
+
+.app-bar.desktop .app-bar-link-area {
+  display: flex;
+}
+
+.app-bar.mobile .app-bar-link-area {
+  position: absolute;
+  z-index: -1;
+  top: 56px;
+  left: 0;
+  right: 0;
+  transition: transform 0.5s;
+}
+
+.app-bar.mobile a {
+  height: 56px;
+}
+
+.app-bar.mobile.collapse .app-bar-link-area {
+  transform: translateY(-100%);
+}
+
+.app-bar .toggler {
+  font-size: 2em;
+  margin-right: 0.5em;
+}
\ No newline at end of file
diff --git a/FrontEnd/src/components/AppBar.tsx b/FrontEnd/src/components/AppBar.tsx
new file mode 100644
index 00000000..da3a946f
--- /dev/null
+++ b/FrontEnd/src/components/AppBar.tsx
@@ -0,0 +1,98 @@
+import { useState } from "react";
+import classnames from "classnames";
+import { Link, NavLink } from "react-router-dom";
+
+import { I18nText, useC, useMobile } from "./common";
+import { useUser } from "~src/services/user";
+
+import TimelineLogo from "./TimelineLogo";
+import { IconButton } from "./button";
+import UserAvatar from "./user/UserAvatar";
+
+import "./AppBar.css";
+
+function AppBarNavLink({
+  link,
+  className,
+  label,
+  onClick,
+  children,
+}: {
+  link: string;
+  className?: string;
+  label?: I18nText;
+  onClick?: () => void;
+  children?: React.ReactNode;
+}) {
+  if (label != null && children != null) {
+    throw new Error("AppBarNavLink: label and children cannot be both set");
+  }
+
+  const c = useC();
+
+  return (
+    <NavLink
+      to={link}
+      className={({ isActive }) => classnames(className, isActive && "active")}
+      onClick={onClick}
+    >
+      {children != null ? children : c(label)}
+    </NavLink>
+  );
+}
+
+export default function AppBar() {
+  const isMobile = useMobile();
+
+  const [isCollapse, setIsCollapse] = useState<boolean>(true);
+  const collapse = isMobile ? () => setIsCollapse(true) : undefined;
+  const toggleCollapse = () => setIsCollapse(!isCollapse);
+
+  const user = useUser();
+  const hasAdministrationPermission = user && user.hasAdministrationPermission;
+
+  return (
+    <nav
+      className={classnames(
+        isMobile ? "mobile" : "desktop",
+        "app-bar",
+        isCollapse && "collapse",
+      )}
+    >
+      <Link to="/" className="app-bar-brand" onClick={collapse}>
+        <TimelineLogo className="app-bar-brand-icon" />
+        Timeline
+      </Link>
+
+      <div className="app-bar-link-area">
+        <AppBarNavLink
+          link="/settings"
+          label="nav.settings"
+          onClick={collapse}
+        />
+        <AppBarNavLink link="/about" label="nav.about" onClick={collapse} />
+        {hasAdministrationPermission && (
+          <AppBarNavLink
+            link="/admin"
+            label="nav.administration"
+            onClick={collapse}
+          />
+        )}
+      </div>
+
+      <div className="app-bar-user-area">
+        {user != null ? (
+          <AppBarNavLink link="/" className="app-bar-avatar" onClick={collapse}>
+            <UserAvatar username={user.username} />
+          </AppBarNavLink>
+        ) : (
+          <AppBarNavLink link="/login" label="nav.login" onClick={collapse} />
+        )}
+      </div>
+
+      {isMobile && (
+        <IconButton icon="list" className="toggler" onClick={toggleCollapse} />
+      )}
+    </nav>
+  );
+}
diff --git a/FrontEnd/src/components/BlobImage.tsx b/FrontEnd/src/components/BlobImage.tsx
new file mode 100644
index 00000000..259c2210
--- /dev/null
+++ b/FrontEnd/src/components/BlobImage.tsx
@@ -0,0 +1,26 @@
+import { ComponentPropsWithoutRef, useState, useEffect } from "react";
+
+type BlobImageProps = Omit<ComponentPropsWithoutRef<"img">, "src"> & {
+  imgRef?: React.Ref<HTMLImageElement>;
+  src?: Blob | string | null;
+};
+
+export default function BlobImage(props: BlobImageProps) {
+  const { imgRef, src, ...otherProps } = props;
+
+  const [url, setUrl] = useState<string | null | undefined>(undefined);
+
+  useEffect(() => {
+    if (src instanceof Blob) {
+      const url = URL.createObjectURL(src);
+      setUrl(url);
+      return () => {
+        URL.revokeObjectURL(url);
+      };
+    } else {
+      setUrl(src);
+    }
+  }, [src]);
+
+  return <img ref={imgRef} {...otherProps} src={url ?? undefined} />;
+}
diff --git a/FrontEnd/src/components/Card.css b/FrontEnd/src/components/Card.css
new file mode 100644
index 00000000..6d655eb9
--- /dev/null
+++ b/FrontEnd/src/components/Card.css
@@ -0,0 +1,20 @@
+.cru-card {
+  border-radius: var(--cru-card-border-radius);
+  transition: all 0.3s;
+}
+
+.cru-card-background-none {
+  background-color: transparent;
+}
+
+.cru-card-background-solid {
+  background-color: var(--cru-background-color);
+}
+
+.cru-card-background-grayscale {
+  background-color: var(--cru-container-background-color);
+}
+
+.cru-card-border-color {
+  border: 2px solid var(--cru-card-border-color);
+}
diff --git a/FrontEnd/src/components/Card.tsx b/FrontEnd/src/components/Card.tsx
new file mode 100644
index 00000000..a8f0d3cc
--- /dev/null
+++ b/FrontEnd/src/components/Card.tsx
@@ -0,0 +1,38 @@
+import { ComponentPropsWithoutRef, Ref } from "react";
+import classNames from "classnames";
+
+import { ThemeColor } from "./common";
+import "./Card.css";
+
+interface CardProps extends ComponentPropsWithoutRef<"div"> {
+  containerRef?: Ref<HTMLDivElement>;
+  color?: ThemeColor;
+  border?: "color" | "none";
+  background?: "color" | "solid" | "grayscale" | "none";
+}
+
+export default function Card({
+  color,
+  background,
+  border,
+  className,
+  children,
+  containerRef,
+  ...otherProps
+}: CardProps) {
+  return (
+    <div
+      ref={containerRef}
+      className={classNames(
+        "cru-card",
+        `cru-card-${color ?? "primary"}`,
+        `cru-card-border-${border ?? "color"}`,
+        `cru-card-background-${background ?? "solid"}`,
+        className,
+      )}
+      {...otherProps}
+    >
+      {children}
+    </div>
+  );
+}
diff --git a/FrontEnd/src/components/Icon.css b/FrontEnd/src/components/Icon.css
new file mode 100644
index 00000000..fe980d7b
--- /dev/null
+++ b/FrontEnd/src/components/Icon.css
@@ -0,0 +1,3 @@
+.cru-icon {
+  font-size: 1.4rem;
+}
diff --git a/FrontEnd/src/components/Icon.tsx b/FrontEnd/src/components/Icon.tsx
new file mode 100644
index 00000000..2ac3a7ca
--- /dev/null
+++ b/FrontEnd/src/components/Icon.tsx
@@ -0,0 +1,30 @@
+import { ComponentPropsWithoutRef } from "react";
+import classNames from "classnames";
+
+import { ThemeColor } from "./common";
+
+import "./Icon.css";
+
+interface IconButtonProps extends ComponentPropsWithoutRef<"i"> {
+  icon: string;
+  color?: ThemeColor | "on-surface";
+  size?: string | number;
+}
+
+export default function Icon(props: IconButtonProps) {
+  const { icon, color, size, style, className, ...otherProps } = props;
+
+  const colorName = color === "on-surface" ? "surface-on" : color;
+
+  return (
+    <i
+      style={size != null ? { ...style, fontSize: size } : style}
+      className={classNames(
+        colorName && `cru-${colorName}`,
+        `bi-${icon} cru-icon`,
+        className,
+      )}
+      {...otherProps}
+    />
+  );
+}
diff --git a/FrontEnd/src/components/ImageCropper.css b/FrontEnd/src/components/ImageCropper.css
new file mode 100644
index 00000000..2c4d0a8c
--- /dev/null
+++ b/FrontEnd/src/components/ImageCropper.css
@@ -0,0 +1,38 @@
+.image-cropper-container {
+  position: relative;
+  box-sizing: border-box;
+  user-select: none;
+}
+
+.image-cropper-container img {
+  position: absolute;
+  left: 0;
+  top: 0;
+  width: 100%;
+  height: 100%;
+}
+
+.image-cropper-mask-container {
+  position: absolute;
+  left: 0;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  overflow: hidden;
+}
+
+.image-cropper-mask {
+  position: absolute;
+  box-shadow: 0 0 0 10000px rgba(255, 255, 255, 0.8);
+  touch-action: none;
+}
+
+.image-cropper-handler {
+  position: absolute;
+  width: 26px;
+  height: 26px;
+  border: black solid 2px;
+  border-radius: 50%;
+  background: white;
+  touch-action: none;
+}
diff --git a/FrontEnd/src/components/ImageCropper.tsx b/FrontEnd/src/components/ImageCropper.tsx
new file mode 100644
index 00000000..f23994e2
--- /dev/null
+++ b/FrontEnd/src/components/ImageCropper.tsx
@@ -0,0 +1,312 @@
+import * as React from "react";
+import classnames from "classnames";
+
+import { UiLogicError } from "~src/common";
+
+import "./ImageCropper.css";
+import BlobImage from "./BlobImage";
+
+export interface Clip {
+  left: number;
+  top: number;
+  width: number;
+}
+
+interface NormailizedClip extends Clip {
+  height: number;
+}
+
+interface ImageInfo {
+  width: number;
+  height: number;
+  landscape: boolean;
+  ratio: number;
+  maxClipWidth: number;
+  maxClipHeight: number;
+}
+
+interface ImageCropperSavedState {
+  clip: NormailizedClip;
+  x: number;
+  y: number;
+  pointerId: number;
+}
+
+export interface ImageCropperProps {
+  clip: Clip | null;
+  image: string | Blob;
+  onChange: (clip: Clip) => void;
+  imageElementCallback?: (element: HTMLImageElement | null) => void;
+  className?: string;
+}
+
+const ImageCropper = (props: ImageCropperProps): React.ReactElement => {
+  const { clip, image, onChange, imageElementCallback, className } = props;
+
+  const [oldState, setOldState] = React.useState<ImageCropperSavedState | null>(
+    null,
+  );
+  const [imageInfo, setImageInfo] = React.useState<ImageInfo | null>(null);
+
+  const normalizeClip = (c: Clip | null | undefined): NormailizedClip => {
+    if (c == null) {
+      return { left: 0, top: 0, width: 0, height: 0 };
+    }
+
+    return {
+      left: c.left || 0,
+      top: c.top || 0,
+      width: c.width || 0,
+      height: imageInfo != null ? (c.width || 0) / imageInfo.ratio : 0,
+    };
+  };
+
+  const c = normalizeClip(clip);
+
+  const imgElementRef = React.useRef<HTMLImageElement | null>(null);
+
+  const onImageRef = React.useCallback(
+    (e: HTMLImageElement | null) => {
+      imgElementRef.current = e;
+      if (imageElementCallback != null && e == null) {
+        imageElementCallback(null);
+      }
+    },
+    [imageElementCallback],
+  );
+
+  const onImageLoad = React.useCallback(
+    (e: React.SyntheticEvent<HTMLImageElement>) => {
+      const img = e.currentTarget;
+      const landscape = img.naturalWidth >= img.naturalHeight;
+
+      const info = {
+        width: img.naturalWidth,
+        height: img.naturalHeight,
+        landscape,
+        ratio: img.naturalHeight / img.naturalWidth,
+        maxClipWidth: landscape ? img.naturalHeight / img.naturalWidth : 1,
+        maxClipHeight: landscape ? 1 : img.naturalWidth / img.naturalHeight,
+      };
+      setImageInfo(info);
+      onChange({ left: 0, top: 0, width: info.maxClipWidth });
+      if (imageElementCallback != null) {
+        imageElementCallback(img);
+      }
+    },
+    [onChange, imageElementCallback],
+  );
+
+  const onPointerDown = React.useCallback(
+    (e: React.PointerEvent) => {
+      if (oldState != null) return;
+      e.currentTarget.setPointerCapture(e.pointerId);
+      setOldState({
+        x: e.clientX,
+        y: e.clientY,
+        clip: c,
+        pointerId: e.pointerId,
+      });
+    },
+    [oldState, c],
+  );
+
+  const onPointerUp = React.useCallback(
+    (e: React.PointerEvent) => {
+      if (oldState == null || oldState.pointerId !== e.pointerId) return;
+      e.currentTarget.releasePointerCapture(e.pointerId);
+      setOldState(null);
+    },
+    [oldState],
+  );
+
+  const onPointerMove = React.useCallback(
+    (e: React.PointerEvent) => {
+      if (oldState == null) return;
+
+      const oldClip = oldState.clip;
+
+      const movement = { x: e.clientX - oldState.x, y: e.clientY - oldState.y };
+
+      const { current: imgElement } = imgElementRef;
+
+      if (imgElement == null) throw new UiLogicError("Image element is null.");
+
+      const moveRatio = {
+        x: movement.x / imgElement.width,
+        y: movement.y / imgElement.height,
+      };
+
+      const newRatio = {
+        x: oldClip.left + moveRatio.x,
+        y: oldClip.top + moveRatio.y,
+      };
+      if (newRatio.x < 0) {
+        newRatio.x = 0;
+      } else if (newRatio.x > 1 - oldClip.width) {
+        newRatio.x = 1 - oldClip.width;
+      }
+      if (newRatio.y < 0) {
+        newRatio.y = 0;
+      } else if (newRatio.y > 1 - oldClip.height) {
+        newRatio.y = 1 - oldClip.height;
+      }
+
+      onChange({ left: newRatio.x, top: newRatio.y, width: oldClip.width });
+    },
+    [oldState, onChange],
+  );
+
+  const onHandlerPointerMove = React.useCallback(
+    (e: React.PointerEvent) => {
+      if (oldState == null) return;
+
+      const oldClip = oldState.clip;
+
+      const movement = { x: e.clientX - oldState.x, y: e.clientY - oldState.y };
+
+      const ratio = imageInfo == null ? 1 : imageInfo.ratio;
+
+      const { current: imgElement } = imgElementRef;
+
+      if (imgElement == null) throw new UiLogicError("Image element is null.");
+
+      const moveRatio = {
+        x: movement.x / imgElement.width,
+        y: movement.x / imgElement.width / ratio,
+      };
+
+      const newRatio = {
+        x: oldClip.width + moveRatio.x,
+        y: oldClip.height + moveRatio.y,
+      };
+
+      const maxRatio = {
+        x: Math.min(1 - oldClip.left, newRatio.x),
+        y: Math.min(1 - oldClip.top, newRatio.y),
+      };
+
+      const maxWidthRatio = Math.min(maxRatio.x, maxRatio.y * ratio);
+
+      let newWidth;
+      if (newRatio.x < 0) {
+        newWidth = 0;
+      } else if (newRatio.x > maxWidthRatio) {
+        newWidth = maxWidthRatio;
+      } else {
+        newWidth = newRatio.x;
+      }
+
+      onChange({ left: oldClip.left, top: oldClip.top, width: newWidth });
+    },
+    [imageInfo, oldState, onChange],
+  );
+
+  const toPercentage = (n: number): string => `${n}%`;
+
+  // fuck!!! I just can't find a better way to implement this in pure css
+  const containerStyle: React.CSSProperties = (() => {
+    if (imageInfo == null) {
+      return { width: "100%", paddingTop: "100%", height: 0 };
+    } else {
+      if (imageInfo.ratio > 1) {
+        return {
+          width: toPercentage(100 / imageInfo.ratio),
+          paddingTop: "100%",
+          height: 0,
+        };
+      } else {
+        return {
+          width: "100%",
+          paddingTop: toPercentage(100 * imageInfo.ratio),
+          height: 0,
+        };
+      }
+    }
+  })();
+
+  return (
+    <div
+      className={classnames("image-cropper-container", className)}
+      style={containerStyle}
+    >
+      <BlobImage
+        imgRef={onImageRef}
+        src={image}
+        onLoad={onImageLoad}
+        alt="to crop"
+      />
+      <div className="image-cropper-mask-container">
+        <div
+          className="image-cropper-mask"
+          style={{
+            left: toPercentage(c.left * 100),
+            top: toPercentage(c.top * 100),
+            width: toPercentage(c.width * 100),
+            height: toPercentage(c.height * 100),
+          }}
+          onPointerMove={onPointerMove}
+          onPointerDown={onPointerDown}
+          onPointerUp={onPointerUp}
+        />
+      </div>
+      <div
+        className="image-cropper-handler"
+        style={{
+          left: `calc(${(c.left + c.width) * 100}% - 15px)`,
+          top: `calc(${(c.top + c.height) * 100}% - 15px)`,
+        }}
+        onPointerMove={onHandlerPointerMove}
+        onPointerDown={onPointerDown}
+        onPointerUp={onPointerUp}
+      />
+    </div>
+  );
+};
+
+export default ImageCropper;
+
+export function applyClipToImage(
+  image: HTMLImageElement,
+  clip: Clip,
+  mimeType: string,
+): Promise<Blob> {
+  return new Promise((resolve, reject) => {
+    const naturalSize = {
+      width: image.naturalWidth,
+      height: image.naturalHeight,
+    };
+    const clipArea = {
+      x: naturalSize.width * clip.left,
+      y: naturalSize.height * clip.top,
+      length: naturalSize.width * clip.width,
+    };
+
+    const canvas = document.createElement("canvas");
+    canvas.width = clipArea.length;
+    canvas.height = clipArea.length;
+    const context = canvas.getContext("2d");
+
+    if (context == null) throw new Error("Failed to create context.");
+
+    context.drawImage(
+      image,
+      clipArea.x,
+      clipArea.y,
+      clipArea.length,
+      clipArea.length,
+      0,
+      0,
+      clipArea.length,
+      clipArea.length,
+    );
+
+    canvas.toBlob((blob) => {
+      if (blob == null) {
+        reject(new Error("canvas.toBlob returns null"));
+      } else {
+        resolve(blob);
+      }
+    }, mimeType);
+  });
+}
diff --git a/FrontEnd/src/components/LoadFailReload.tsx b/FrontEnd/src/components/LoadFailReload.tsx
new file mode 100644
index 00000000..81ba1f67
--- /dev/null
+++ b/FrontEnd/src/components/LoadFailReload.tsx
@@ -0,0 +1,37 @@
+import * as React from "react";
+import { Trans } from "react-i18next";
+
+export interface LoadFailReloadProps {
+  className?: string;
+  style?: React.CSSProperties;
+  onReload: () => void;
+}
+
+const LoadFailReload: React.FC<LoadFailReloadProps> = ({
+  onReload,
+  className,
+  style,
+}) => {
+  return (
+    <Trans
+      i18nKey="loadFailReload"
+      parent="div"
+      className={className}
+      style={style}
+    >
+      0
+      <a
+        href="#"
+        onClick={(e) => {
+          onReload();
+          e.preventDefault();
+        }}
+      >
+        1
+      </a>
+      2
+    </Trans>
+  );
+};
+
+export default LoadFailReload;
diff --git a/FrontEnd/src/components/LoadingPage.tsx b/FrontEnd/src/components/LoadingPage.tsx
new file mode 100644
index 00000000..35ee1aa8
--- /dev/null
+++ b/FrontEnd/src/components/LoadingPage.tsx
@@ -0,0 +1,13 @@
+import * as React from "react";
+
+import Spinner from "./Spinner";
+
+const LoadingPage: React.FC = () => {
+  return (
+    <div className="position-fixed w-100 h-100 d-flex justify-content-center align-items-center">
+      <Spinner />
+    </div>
+  );
+};
+
+export default LoadingPage;
diff --git a/FrontEnd/src/components/Page.tsx b/FrontEnd/src/components/Page.tsx
new file mode 100644
index 00000000..86fdb2f5
--- /dev/null
+++ b/FrontEnd/src/components/Page.tsx
@@ -0,0 +1,15 @@
+import { ComponentPropsWithoutRef, Ref } from "react";
+import classNames from "classnames";
+
+interface PageProps extends ComponentPropsWithoutRef<"div"> {
+  noTopPadding?: boolean;
+  pageRef?: Ref<HTMLDivElement>;
+}
+
+export default function Page({ noTopPadding, pageRef, className, children }: PageProps) {
+  return (
+    <div ref={pageRef} className={classNames(className, "cru-page", noTopPadding && "cru-page-no-top-padding")}>
+      {children}
+    </div>
+  );
+}
diff --git a/FrontEnd/src/components/SearchInput.css b/FrontEnd/src/components/SearchInput.css
new file mode 100644
index 00000000..f0503016
--- /dev/null
+++ b/FrontEnd/src/components/SearchInput.css
@@ -0,0 +1,8 @@
+.cru-search-input {
+  display: flex;
+  flex-wrap: wrap;
+}
+
+.cru-search-input-input {
+  width: 100%;
+}
diff --git a/FrontEnd/src/components/SearchInput.tsx b/FrontEnd/src/components/SearchInput.tsx
new file mode 100644
index 00000000..e3216b86
--- /dev/null
+++ b/FrontEnd/src/components/SearchInput.tsx
@@ -0,0 +1,50 @@
+import classNames from "classnames";
+
+import { useC, Text } from "./common";
+import LoadingButton from "./button/LoadingButton";
+
+import "./SearchInput.css";
+
+interface SearchInputProps {
+  value: string;
+  onChange: (value: string) => void;
+  onButtonClick: () => void;
+  loading?: boolean;
+  className?: string;
+  buttonText?: Text;
+}
+
+export default function SearchInput({
+  value,
+  onChange,
+  onButtonClick,
+  loading,
+  className,
+  buttonText,
+}: SearchInputProps) {
+  const c = useC();
+
+  return (
+    <div className={classNames("cru-search-input", className)}>
+      <input
+        type="text"
+        className="cru-search-input-input"
+        value={value}
+        onChange={(event) => {
+          const { value } = event.currentTarget;
+          onChange(value);
+        }}
+        onKeyDown={(event) => {
+          if (event.key === "Enter") {
+            onButtonClick();
+            event.preventDefault();
+          }
+        }}
+      />
+
+      <LoadingButton loading={loading} onClick={onButtonClick}>
+        {c(buttonText ?? "search")}
+      </LoadingButton>
+    </div>
+  );
+}
diff --git a/FrontEnd/src/components/Skeleton.css b/FrontEnd/src/components/Skeleton.css
new file mode 100644
index 00000000..a571eead
--- /dev/null
+++ b/FrontEnd/src/components/Skeleton.css
@@ -0,0 +1,14 @@
+.cru-skeleton {
+  padding: 0 1em;
+}
+
+.cru-skeleton-line {
+  height: 1em;
+  background-color: hsl(0, 0%, 90%);
+  margin: 0.7em 0;
+  border-radius: 0.2em;
+}
+
+.cru-skeleton-line.last {
+  width: 50%;
+}
diff --git a/FrontEnd/src/components/Skeleton.tsx b/FrontEnd/src/components/Skeleton.tsx
new file mode 100644
index 00000000..3b149db9
--- /dev/null
+++ b/FrontEnd/src/components/Skeleton.tsx
@@ -0,0 +1,32 @@
+import * as React from "react";
+import classnames from "classnames";
+import range from "lodash/range";
+
+import "./Skeleton.css";
+
+export interface SkeletonProps {
+  lineNumber?: number;
+  className?: string;
+  style?: React.CSSProperties;
+}
+
+const Skeleton: React.FC<SkeletonProps> = (props) => {
+  const { lineNumber: lineNumberProps, className, style } = props;
+  const lineNumber = lineNumberProps ?? 3;
+
+  return (
+    <div className={classnames(className, "cru-skeleton")} style={style}>
+      {range(lineNumber).map((i) => (
+        <div
+          key={i}
+          className={classnames(
+            "cru-skeleton-line",
+            i === lineNumber - 1 && "last"
+          )}
+        />
+      ))}
+    </div>
+  );
+};
+
+export default Skeleton;
diff --git a/FrontEnd/src/components/Spinner.css b/FrontEnd/src/components/Spinner.css
new file mode 100644
index 00000000..a1de68d2
--- /dev/null
+++ b/FrontEnd/src/components/Spinner.css
@@ -0,0 +1,13 @@
+@keyframes cru-spinner-animation {
+  from {
+    transform: scale(0,0);
+  }
+}
+
+.cru-spinner {
+  display: inline-block;
+  animation: cru-spinner-animation 0.5s infinite alternate;
+  background-color: currentColor;
+  border-radius: 50%;
+  transform-origin: center;
+}
diff --git a/FrontEnd/src/components/Spinner.tsx b/FrontEnd/src/components/Spinner.tsx
new file mode 100644
index 00000000..ec0c2c35
--- /dev/null
+++ b/FrontEnd/src/components/Spinner.tsx
@@ -0,0 +1,36 @@
+import { CSSProperties } from "react";
+import classnames from "classnames";
+
+import { ThemeColor } from "./common";
+
+import "./Spinner.css";
+
+export interface SpinnerProps {
+  size?: "sm" | "md" | "lg" | number | string;
+  color?: ThemeColor;
+  className?: string;
+  style?: CSSProperties;
+}
+
+export default function Spinner(props: SpinnerProps) {
+  const { size, color, className, style } = props;
+  const calculatedSize =
+    size === "sm"
+      ? "18px"
+      : size === "md"
+      ? "30px"
+      : size === "lg"
+      ? "42px"
+      : typeof size === "number"
+      ? size
+      : size == null
+      ? "20px"
+      : size;
+
+  return (
+    <span
+      className={classnames("cru-spinner", color && `cru-${color}`, className)}
+      style={{ width: calculatedSize, height: calculatedSize, ...style }}
+    />
+  );
+}
diff --git a/FrontEnd/src/components/TimelineLogo.tsx b/FrontEnd/src/components/TimelineLogo.tsx
new file mode 100644
index 00000000..e06ed0f5
--- /dev/null
+++ b/FrontEnd/src/components/TimelineLogo.tsx
@@ -0,0 +1,27 @@
+import { SVGAttributes } from "react";
+import * as React from "react";
+
+export interface TimelineLogoProps extends SVGAttributes<SVGElement> {
+  color?: string;
+}
+
+const TimelineLogo: React.FC<TimelineLogoProps> = (props) => {
+  const { color, ...forwardProps } = props;
+  const coercedColor = color ?? "currentcolor";
+  return (
+    <svg
+      className={props.className}
+      viewBox="0 0 100 100"
+      fill="none"
+      strokeWidth="12"
+      stroke={coercedColor}
+      {...forwardProps}
+    >
+      <line x1="50" y1="0" x2="50" y2="25" />
+      <circle cx="50" cy="50" r="22" />
+      <line x1="50" y1="75" x2="50" y2="100" />
+    </svg>
+  );
+};
+
+export default TimelineLogo;
diff --git a/FrontEnd/src/components/alert/AlertHost.tsx b/FrontEnd/src/components/alert/AlertHost.tsx
new file mode 100644
index 00000000..b234ac03
--- /dev/null
+++ b/FrontEnd/src/components/alert/AlertHost.tsx
@@ -0,0 +1,113 @@
+import * as React from "react";
+import without from "lodash/without";
+import { useTranslation } from "react-i18next";
+import classNames from "classnames";
+
+import { alertService, AlertInfoEx, AlertInfo } from "~src/services/alert";
+import { convertI18nText } from "~src/common";
+
+import IconButton from "../button/IconButton";
+
+import "./alert.css";
+
+interface AutoCloseAlertProps {
+  alert: AlertInfo;
+  close: () => void;
+}
+
+export const AutoCloseAlert: React.FC<AutoCloseAlertProps> = (props) => {
+  const { alert, close } = props;
+  const { dismissTime } = alert;
+
+  const { t } = useTranslation();
+
+  const timerTag = React.useRef<number | null>(null);
+  const closeHandler = React.useRef<(() => void) | null>(null);
+
+  React.useEffect(() => {
+    closeHandler.current = close;
+  }, [close]);
+
+  React.useEffect(() => {
+    const tag =
+      dismissTime === "never"
+        ? null
+        : typeof dismissTime === "number"
+        ? window.setTimeout(() => closeHandler.current?.(), dismissTime)
+        : window.setTimeout(() => closeHandler.current?.(), 5000);
+    timerTag.current = tag;
+    return () => {
+      if (tag != null) {
+        window.clearTimeout(tag);
+      }
+    };
+  }, [dismissTime]);
+
+  const cancelTimer = (): void => {
+    const { current: tag } = timerTag;
+    if (tag != null) {
+      window.clearTimeout(tag);
+    }
+  };
+
+  return (
+    <div
+      className={classNames(
+        "m-3 cru-alert",
+        "cru-" + (alert.type ?? "primary")
+      )}
+      onClick={cancelTimer}
+    >
+      <div className="cru-alert-content">
+        {(() => {
+          const { message, customMessage } = alert;
+          if (customMessage != null) {
+            return customMessage;
+          } else {
+            return convertI18nText(message, t);
+          }
+        })()}
+      </div>
+      <div className="cru-alert-close-button-container">
+        <IconButton
+          icon="x"
+          className="cru-alert-close-button"
+          onClick={close}
+        />
+      </div>
+    </div>
+  );
+};
+
+const AlertHost: React.FC = () => {
+  const [alerts, setAlerts] = React.useState<AlertInfoEx[]>([]);
+
+  React.useEffect(() => {
+    const consume = (alert: AlertInfoEx): void => {
+      setAlerts((old) => [...old, alert]);
+    };
+
+    alertService.registerConsumer(consume);
+    return () => {
+      alertService.unregisterConsumer(consume);
+    };
+  }, []);
+
+  return (
+    <div className="alert-container">
+      {alerts.map((alert) => {
+        return (
+          <AutoCloseAlert
+            key={alert.id}
+            alert={alert}
+            close={() => {
+              setAlerts((old) => without(old, alert));
+            }}
+          />
+        );
+      })}
+    </div>
+  );
+};
+
+export default AlertHost;
diff --git a/FrontEnd/src/components/alert/alert.css b/FrontEnd/src/components/alert/alert.css
new file mode 100644
index 00000000..54c2b87f
--- /dev/null
+++ b/FrontEnd/src/components/alert/alert.css
@@ -0,0 +1,33 @@
+.alert-container {
+  position: fixed;
+  z-index: 1040;
+}
+
+.cru-alert {
+  border-radius: 5px;
+  border: var(--cru-key-color) 1px solid;
+  color: var(--cru-key-t-color);
+  background-color: var(--cru-key-b1-color);
+
+  display: flex;
+  overflow: hidden;
+}
+
+.cru-alert-content {
+  padding: 0.5em 2em;
+}
+
+.cru-alert-close-button-container {
+  flex-shrink: 0;
+  margin-left: auto;
+  width: 2em;
+  text-align: center;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background-color: var(--cru-key-t-color);
+}
+
+.cru-alert-close-button {
+  color: var(--cru-key-color);
+}
diff --git a/FrontEnd/src/components/breakpoints.ts b/FrontEnd/src/components/breakpoints.ts
new file mode 100644
index 00000000..fb281610
--- /dev/null
+++ b/FrontEnd/src/components/breakpoints.ts
@@ -0,0 +1,3 @@
+export const breakpoints = {
+  sm: 576,
+} as const;
diff --git a/FrontEnd/src/components/button/Button.css b/FrontEnd/src/components/button/Button.css
new file mode 100644
index 00000000..1da70f0e
--- /dev/null
+++ b/FrontEnd/src/components/button/Button.css
@@ -0,0 +1,64 @@
+.cru-button {
+  font-size: 1rem;
+  padding: 0.4em 0.8em;
+  transition: all 0.3s;
+  border-radius: 0.2em;
+  border: 1px solid;
+  cursor: pointer;
+}
+
+.cru-button:not(.outline) {
+  color: var(--cru-push-button-text-color);
+  background-color: var(--cru-clickable-normal-color);
+  border-color: var(--cru-clickable-normal-color);
+}
+
+.cru-button:not(.outline):hover {
+  background-color: var(--cru-clickable-hover-color);
+  border-color: var(--cru-clickable-hover-color);
+}
+
+.cru-button:not(.outline):focus {
+  background-color: var(--cru-clickable-focus-color);
+  border-color: var(--cru-clickable-focus-color);
+}
+
+.cru-button:not(.outline):active {
+  background-color: var(--cru-clickable-active-color);
+  border-color: var(--cru-clickable-active-color);
+}
+
+.cru-button:not(.outline):disabled {
+  color: var(--cru-push-button-disabled-text-color);
+  background-color: var(--cru-push-button-disabled-color);
+  border-color: var(--cru-push-button-disabled-color);
+  cursor: auto;
+}
+
+
+.cru-button.outline {
+  color: var(--cru-clickable-normal-color);
+  border-color: var(--cru-clickable-normal-color);
+  background-color: transparent;
+}
+
+.cru-button.outline:hover {
+  color: var(--cru-clickable-hover-color);
+  border-color: var(--cru-clickable-hover-color);
+}
+
+.cru-button.outline:focus {
+  color: var(--cru-clickable-focus-color);
+  border-color: var(--cru-clickable-focus-color);
+}
+
+.cru-button.outline:active {
+  color: var(--cru-clickable-active-color);
+  border-color: var(--cru-clickable-active-color);
+}
+
+.cru-button.outline:disabled {
+  color: var(--cru-clickable-disabled-color);
+  border-color: var(--cru-clickable-disabled-color);
+  cursor: auto;
+}
diff --git a/FrontEnd/src/components/button/Button.tsx b/FrontEnd/src/components/button/Button.tsx
new file mode 100644
index 00000000..6c38e130
--- /dev/null
+++ b/FrontEnd/src/components/button/Button.tsx
@@ -0,0 +1,46 @@
+import { ComponentPropsWithoutRef, Ref } from "react";
+import classNames from "classnames";
+
+import { Text, useC, ThemeColor } from "../common";
+
+import "./Button.css";
+
+interface ButtonProps extends ComponentPropsWithoutRef<"button"> {
+  color?: ThemeColor;
+  text?: Text;
+  outline?: boolean;
+  buttonRef?: Ref<HTMLButtonElement> | null;
+}
+
+export default function Button(props: ButtonProps) {
+  const {
+    buttonRef,
+    color,
+    text,
+    outline,
+    className,
+    children,
+    ...otherProps
+  } = props;
+
+  if (text != null && children != null) {
+    console.warn("You can't set both text and children props.");
+  }
+
+  const c = useC();
+
+  return (
+    <button
+      ref={buttonRef}
+      className={classNames(
+        "cru-button",
+        `cru-clickable-${color ?? "primary"}`,
+        outline && "outline",
+        className,
+      )}
+      {...otherProps}
+    >
+      {text != null ? c(text) : children}
+    </button>
+  );
+}
diff --git a/FrontEnd/src/components/button/ButtonRow.css b/FrontEnd/src/components/button/ButtonRow.css
new file mode 100644
index 00000000..e69de29b
diff --git a/FrontEnd/src/components/button/ButtonRow.tsx b/FrontEnd/src/components/button/ButtonRow.tsx
new file mode 100644
index 00000000..eea60cc4
--- /dev/null
+++ b/FrontEnd/src/components/button/ButtonRow.tsx
@@ -0,0 +1,62 @@
+import { ComponentPropsWithoutRef, Ref } from "react";
+import classNames from "classnames";
+
+import Button from "./Button";
+import FlatButton from "./FlatButton";
+import IconButton from "./IconButton";
+import LoadingButton from "./LoadingButton";
+
+import "./ButtonRow.css";
+
+type ButtonRowButton = (
+  | {
+      type: "normal";
+      props: ComponentPropsWithoutRef<typeof Button>;
+    }
+  | {
+      type: "flat";
+      props: ComponentPropsWithoutRef<typeof FlatButton>;
+    }
+  | {
+      type: "icon";
+      props: ComponentPropsWithoutRef<typeof IconButton>;
+    }
+  | { type: "loading"; props: ComponentPropsWithoutRef<typeof LoadingButton> }
+) & { key: string | number };
+
+interface ButtonRowProps {
+  className?: string;
+  containerRef?: Ref<HTMLDivElement>;
+  buttons: ButtonRowButton[];
+  buttonsClassName?: string;
+}
+
+export default function ButtonRow({
+  className,
+  containerRef,
+  buttons,
+  buttonsClassName,
+}: ButtonRowProps) {
+  return (
+    <div ref={containerRef} className={classNames("cru-button-row", className)}>
+      {buttons.map((button) => {
+        const { type, key, props } = button;
+        const newClassName = classNames(props.className, buttonsClassName);
+        switch (type) {
+          case "normal":
+            return <Button key={key} {...props} className={newClassName} />;
+          case "flat":
+            return <FlatButton key={key} {...props} className={newClassName} />;
+          case "icon":
+            return <IconButton key={key} {...props} className={newClassName} />;
+          case "loading":
+            return (
+              <LoadingButton key={key} {...props} className={newClassName} />
+            );
+          default:
+            throw new Error();
+        }
+      })}
+    </div>
+  );
+}
diff --git a/FrontEnd/src/components/button/ButtonRowV2.tsx b/FrontEnd/src/components/button/ButtonRowV2.tsx
new file mode 100644
index 00000000..3467ad52
--- /dev/null
+++ b/FrontEnd/src/components/button/ButtonRowV2.tsx
@@ -0,0 +1,143 @@
+import { ComponentPropsWithoutRef, Ref } from "react";
+import classNames from "classnames";
+
+import Button from "./Button";
+import FlatButton from "./FlatButton";
+import IconButton from "./IconButton";
+import LoadingButton from "./LoadingButton";
+
+import "./ButtonRow.css";
+import { Text, ThemeColor } from "../common";
+
+interface ButtonRowV2ButtonBase {
+  key: string | number;
+  action?: "primary" | "secondary";
+  color?: ThemeColor;
+  disabled?: boolean;
+  onClick?: () => void;
+}
+
+interface ButtonRowV2ButtonWithNoType extends ButtonRowV2ButtonBase {
+  type?: undefined | null;
+  text: Text;
+  outline?: boolean;
+  props?: ComponentPropsWithoutRef<typeof Button>;
+}
+
+interface ButtonRowV2NormalButton extends ButtonRowV2ButtonBase {
+  type: "normal";
+  text: Text;
+  outline?: boolean;
+  props?: ComponentPropsWithoutRef<typeof Button>;
+}
+
+interface ButtonRowV2FlatButton extends ButtonRowV2ButtonBase {
+  type: "flat";
+  text: Text;
+  props?: ComponentPropsWithoutRef<typeof FlatButton>;
+}
+
+interface ButtonRowV2IconButton extends ButtonRowV2ButtonBase {
+  type: "icon";
+  icon: string;
+  props?: ComponentPropsWithoutRef<typeof IconButton>;
+}
+
+interface ButtonRowV2LoadingButton extends ButtonRowV2ButtonBase {
+  type: "loading";
+  text: Text;
+  loading?: boolean;
+  props?: ComponentPropsWithoutRef<typeof LoadingButton>;
+}
+
+type ButtonRowV2Button =
+  | ButtonRowV2ButtonWithNoType
+  | ButtonRowV2NormalButton
+  | ButtonRowV2FlatButton
+  | ButtonRowV2IconButton
+  | ButtonRowV2LoadingButton;
+
+interface ButtonRowV2Props {
+  className?: string;
+  containerRef?: Ref<HTMLDivElement>;
+  buttons: ButtonRowV2Button[];
+  buttonsClassName?: string;
+}
+
+export default function ButtonRowV2({
+  className,
+  containerRef,
+  buttons,
+  buttonsClassName,
+}: ButtonRowV2Props) {
+  return (
+    <div ref={containerRef} className={classNames("cru-button-row", className)}>
+      {buttons.map((button) => {
+        const { key, action, color, disabled, onClick } = button;
+
+        const realAction = action ?? "primary";
+        const realColor =
+          color ?? (realAction === "primary" ? "primary" : "secondary");
+
+        const commonProps = { key, color: realColor, disabled, onClick };
+        const newClassName = classNames(
+          button.props?.className,
+          buttonsClassName,
+        );
+
+        switch (button.type) {
+          case null:
+          case undefined:
+          case "normal": {
+            const { text, outline, props } = button;
+            return (
+              <Button
+                {...commonProps}
+                text={text}
+                outline={outline ?? realAction !== "primary"}
+                {...props}
+                className={newClassName}
+              />
+            );
+          }
+          case "flat": {
+            const { text, props } = button;
+            return (
+              <FlatButton
+                {...commonProps}
+                text={text}
+                {...props}
+                className={newClassName}
+              />
+            );
+          }
+          case "icon": {
+            const { icon, props } = button;
+            return (
+              <IconButton
+                {...commonProps}
+                icon={icon}
+                {...props}
+                className={newClassName}
+              />
+            );
+          }
+          case "loading": {
+            const { text, loading, props } = button;
+            return (
+              <LoadingButton
+                {...commonProps}
+                text={text}
+                loading={loading}
+                {...props}
+                className={newClassName}
+              />
+            );
+          }
+          default:
+            throw new Error();
+        }
+      })}
+    </div>
+  );
+}
diff --git a/FrontEnd/src/components/button/FlatButton.css b/FrontEnd/src/components/button/FlatButton.css
new file mode 100644
index 00000000..2050946c
--- /dev/null
+++ b/FrontEnd/src/components/button/FlatButton.css
@@ -0,0 +1,27 @@
+.cru-flat-button {
+  font-size: 1rem;
+  padding: 0.4em 0.8em;
+  transition: all 0.5s;
+  border-radius: 0.2em;
+  background-color: var(--cru-clickable-grayscale-normal-color);
+  border: 1px none;
+  color: var(--cru-clickable-normal-color);
+  cursor: pointer;
+}
+
+.cru-flat-button:hover {
+  background-color: var(--cru-clickable-grayscale-hover-color);
+}
+
+.cru-flat-button:focus {
+  background-color: var(--cru-clickable-grayscale-focus-color);
+}
+
+.cru-flat-button:active {
+  background-color: var(--cru-clickable-grayscale-active-color);
+}
+
+.cru-flat-button:disabled {
+  color: var(--cru-clickable-disabled-color);
+  cursor: auto;
+}
\ No newline at end of file
diff --git a/FrontEnd/src/components/button/FlatButton.tsx b/FrontEnd/src/components/button/FlatButton.tsx
new file mode 100644
index 00000000..9f074dd6
--- /dev/null
+++ b/FrontEnd/src/components/button/FlatButton.tsx
@@ -0,0 +1,36 @@
+import { ComponentPropsWithoutRef, Ref } from "react";
+import classNames from "classnames";
+
+import { Text, useC, ThemeColor } from "../common";
+
+import "./FlatButton.css";
+
+interface FlatButtonProps extends ComponentPropsWithoutRef<"button"> {
+  color?: ThemeColor;
+  text?: Text;
+  buttonRef?: Ref<HTMLButtonElement> | null;
+}
+
+export default function FlatButton(props: FlatButtonProps) {
+  const { color, text, className, children, buttonRef, ...otherProps } = props;
+
+  if (text != null && children != null) {
+    console.warn("You can't set both text and children props.");
+  }
+
+  const c = useC();
+
+  return (
+    <button
+      ref={buttonRef}
+      className={classNames(
+        "cru-flat-button",
+        `cru-clickable-${color ?? "primary"}`,
+        className,
+      )}
+      {...otherProps}
+    >
+      {text != null ? c(text) : children}
+    </button>
+  );
+}
diff --git a/FrontEnd/src/components/button/IconButton.css b/FrontEnd/src/components/button/IconButton.css
new file mode 100644
index 00000000..a3747201
--- /dev/null
+++ b/FrontEnd/src/components/button/IconButton.css
@@ -0,0 +1,30 @@
+.cru-icon-button {
+  color: var(--cru-clickable-normal-color);
+  font-size: 1.4rem;
+  background: none;
+  border: none;
+  transition: all 0.5s;
+  cursor: pointer;
+  user-select: none;
+}
+
+.cru-icon-button:hover {
+  color: var(--cru-clickable-hover-color);
+}
+
+.cru-icon-button:focus {
+  color: var(--cru-clickable-focus-color);
+}
+
+.cru-icon-button:active {
+  color: var(--cru-clickable-active-color);
+}
+
+.cru-flat-button:disabled {
+  color: var(--cru-clickable-disabled-color);
+  cursor: auto;
+}
+
+.cru-icon-button.large {
+  font-size: 1.6rem;
+}
diff --git a/FrontEnd/src/components/button/IconButton.tsx b/FrontEnd/src/components/button/IconButton.tsx
new file mode 100644
index 00000000..95c58887
--- /dev/null
+++ b/FrontEnd/src/components/button/IconButton.tsx
@@ -0,0 +1,30 @@
+import { ComponentPropsWithoutRef } from "react";
+import classNames from "classnames";
+
+import { ThemeColor } from "../common";
+
+import "./IconButton.css";
+
+interface IconButtonProps extends ComponentPropsWithoutRef<"i"> {
+  icon: string;
+  color?: ThemeColor | "grayscale";
+  large?: boolean;
+  disabled?: boolean; // TODO: Not implemented
+}
+
+export default function IconButton(props: IconButtonProps) {
+  const { icon, color, className, large, ...otherProps } = props;
+
+  return (
+    <button
+      className={classNames(
+        "cru-icon-button",
+        `cru-clickable-${color ?? "grayscale"}`,
+        large && "large",
+        "bi-" + icon,
+        className,
+      )}
+      {...otherProps}
+    />
+  );
+}
diff --git a/FrontEnd/src/components/button/LoadingButton.css b/FrontEnd/src/components/button/LoadingButton.css
new file mode 100644
index 00000000..23fadd3d
--- /dev/null
+++ b/FrontEnd/src/components/button/LoadingButton.css
@@ -0,0 +1,13 @@
+.cru-loading-button {
+  display: flex;
+  align-items: center;
+}
+
+.cru-loading-button-spinner {
+  margin-left: 0.5em;
+}
+
+.cru-loading-button-loading {
+  color: var(--cru-clickable-normal-color) !important;
+  border-color: var(--cru-clickable-normal-color) !important;
+}
\ No newline at end of file
diff --git a/FrontEnd/src/components/button/LoadingButton.tsx b/FrontEnd/src/components/button/LoadingButton.tsx
new file mode 100644
index 00000000..7e7d08e6
--- /dev/null
+++ b/FrontEnd/src/components/button/LoadingButton.tsx
@@ -0,0 +1,40 @@
+import classNames from "classnames";
+
+import { I18nText, ThemeColor, useC } from "../common";
+
+import Spinner from "../Spinner";
+
+import "./LoadingButton.css";
+
+interface LoadingButtonProps extends React.ComponentPropsWithoutRef<"button"> {
+  color?: ThemeColor;
+  text?: I18nText;
+  loading?: boolean;
+}
+
+export default function LoadingButton(props: LoadingButtonProps) {
+  const c = useC();
+
+  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.");
+  }
+
+  return (
+    <button
+      disabled={disabled || loading}
+      className={classNames(
+        "cru-button outline cru-loading-button",
+        `cru-clickable-${color ?? "primary"}`,
+        loading && "cru-loading-button-loading",
+        className,
+      )}
+      {...otherProps}
+    >
+      {text != null ? c(text) : children}
+      {loading && <Spinner className="cru-loading-button-spinner" />}
+    </button>
+  );
+}
diff --git a/FrontEnd/src/components/button/index.tsx b/FrontEnd/src/components/button/index.tsx
new file mode 100644
index 00000000..b5aa5470
--- /dev/null
+++ b/FrontEnd/src/components/button/index.tsx
@@ -0,0 +1,15 @@
+import Button from "./Button";
+import FlatButton from "./FlatButton";
+import IconButton from "./IconButton";
+import LoadingButton from "./LoadingButton";
+import ButtonRow from "./ButtonRow";
+import ButtonRowV2 from "./ButtonRowV2";
+
+export {
+  Button,
+  FlatButton,
+  IconButton,
+  LoadingButton,
+  ButtonRow,
+  ButtonRowV2,
+};
diff --git a/FrontEnd/src/components/common.ts b/FrontEnd/src/components/common.ts
new file mode 100644
index 00000000..e6f7319f
--- /dev/null
+++ b/FrontEnd/src/components/common.ts
@@ -0,0 +1,14 @@
+export type { Text, I18nText } from "~src/common";
+export { UiLogicError, c, convertI18nText, useC } from "~src/common";
+
+export const themeColors = [
+  "primary",
+  "secondary",
+  "danger",
+  "create",
+] as const;
+
+export type ThemeColor = (typeof themeColors)[number];
+
+export { breakpoints } from "./breakpoints";
+export { useMobile } from "./hooks";
diff --git a/FrontEnd/src/components/dialog/ConfirmDialog.css b/FrontEnd/src/components/dialog/ConfirmDialog.css
new file mode 100644
index 00000000..e69de29b
diff --git a/FrontEnd/src/components/dialog/ConfirmDialog.tsx b/FrontEnd/src/components/dialog/ConfirmDialog.tsx
new file mode 100644
index 00000000..26939c9b
--- /dev/null
+++ b/FrontEnd/src/components/dialog/ConfirmDialog.tsx
@@ -0,0 +1,59 @@
+import { useC, Text, ThemeColor } from "../common";
+
+import Dialog from "./Dialog";
+import DialogContainer from "./DialogContainer";
+
+export default function ConfirmDialog({
+  open,
+  onClose,
+  onConfirm,
+  title,
+  body,
+  color,
+  bodyColor,
+}: {
+  open: boolean;
+  onClose: () => void;
+  onConfirm: () => void;
+  title: Text;
+  body: Text;
+  color?: ThemeColor;
+  bodyColor?: ThemeColor;
+}) {
+  const c = useC();
+
+  return (
+    <Dialog onClose={onClose} open={open}>
+      <DialogContainer
+        title={title}
+        titleColor={color ?? "danger"}
+        buttons={[
+          {
+            key: "cancel",
+            type: "normal",
+            props: {
+              text: "operationDialog.cancel",
+              color: "secondary",
+              outline: true,
+              onClick: onClose,
+            },
+          },
+          {
+            key: "confirm",
+            type: "normal",
+            props: {
+              text: "operationDialog.confirm",
+              color: "danger",
+              onClick: () => {
+                onConfirm();
+                onClose();
+              },
+            },
+          },
+        ]}
+      >
+        <div className={`cru-${bodyColor ?? "primary"}`}>{c(body)}</div>
+      </DialogContainer>
+    </Dialog>
+  );
+}
diff --git a/FrontEnd/src/components/dialog/Dialog.css b/FrontEnd/src/components/dialog/Dialog.css
new file mode 100644
index 00000000..e4c61440
--- /dev/null
+++ b/FrontEnd/src/components/dialog/Dialog.css
@@ -0,0 +1,60 @@
+.cru-dialog-overlay {
+  position: fixed;
+  z-index: 1040;
+  left: 0;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  display: flex;
+  align-items: center;
+  overflow: auto;
+}
+
+.cru-dialog-background {
+  position: absolute;
+  z-index: -1;
+  left: 0;
+  right: 0;
+  top: 0;
+  bottom: 0;
+  background-color: var(--cru-surface-dim-color);
+  opacity: 0.8;
+}
+
+.cru-dialog-container {
+  max-width: 100%;
+  min-width: 30vw;
+
+  margin: 2em auto;
+
+  border: var(--cru-key-container-color) 1px solid;
+  border-radius: 5px;
+  padding: 1.5em;
+  background-color: var(--cru-surface-color);
+}
+
+@media (min-width: 576px) {
+  .cru-dialog-container {
+    max-width: 800px;
+  }
+}
+
+.cru-dialog-enter .cru-dialog-container {
+  transform: scale(0, 0);
+  opacity: 0;
+  transform-origin: center;
+}
+
+.cru-dialog-enter-active .cru-dialog-container {
+  transform: scale(1, 1);
+  opacity: 1;
+  transition: transform 0.3s, opacity 0.3s;
+  transform-origin: center;
+}
+
+.cru-dialog-exit-active .cru-dialog-container {
+  transition: transform 0.3s, opacity 0.3s;
+  transform: scale(0, 0);
+  opacity: 0;
+  transform-origin: center;
+}
\ No newline at end of file
diff --git a/FrontEnd/src/components/dialog/Dialog.tsx b/FrontEnd/src/components/dialog/Dialog.tsx
new file mode 100644
index 00000000..2ff7bea8
--- /dev/null
+++ b/FrontEnd/src/components/dialog/Dialog.tsx
@@ -0,0 +1,63 @@
+import { ReactNode, useRef } from "react";
+import ReactDOM from "react-dom";
+import { CSSTransition } from "react-transition-group";
+import classNames from "classnames";
+
+import { ThemeColor } from "../common";
+
+import "./Dialog.css";
+
+const optionalPortalElement = document.getElementById("portal");
+if (optionalPortalElement == null) {
+  throw new Error("Portal element not found");
+}
+const portalElement = optionalPortalElement;
+
+interface DialogProps {
+  open: boolean;
+  onClose: () => void;
+  color?: ThemeColor;
+  children?: ReactNode;
+  disableCloseOnClickOnOverlay?: boolean;
+}
+
+export default function Dialog({
+  open,
+  onClose,
+  color,
+  children,
+  disableCloseOnClickOnOverlay,
+}: DialogProps) {
+  color = color ?? "primary";
+
+  const nodeRef = useRef(null);
+
+  return ReactDOM.createPortal(
+    <CSSTransition
+      nodeRef={nodeRef}
+      mountOnEnter
+      unmountOnExit
+      in={open}
+      timeout={300}
+      classNames="cru-dialog"
+    >
+      <div
+        ref={nodeRef}
+        className={classNames("cru-dialog-overlay", `cru-${color}`)}
+      >
+        <div
+          className="cru-dialog-background"
+          onClick={
+            disableCloseOnClickOnOverlay
+              ? undefined
+              : () => {
+                  onClose();
+                }
+          }
+        />
+        <div className="cru-dialog-container">{children}</div>
+      </div>
+    </CSSTransition>,
+    portalElement,
+  );
+}
diff --git a/FrontEnd/src/components/dialog/DialogContainer.css b/FrontEnd/src/components/dialog/DialogContainer.css
new file mode 100644
index 00000000..fbb18e0d
--- /dev/null
+++ b/FrontEnd/src/components/dialog/DialogContainer.css
@@ -0,0 +1,20 @@
+.cru-dialog-container-title {
+  font-size: 1.2em;
+  font-weight: bold;
+  color: var(--cru-key-color);
+  margin-bottom: 0.5em;
+}
+
+
+.cru-dialog-container-hr {
+  margin: 1em 0;
+}
+
+.cru-dialog-container-button-row {
+  display: flex;
+  justify-content: flex-end;
+}
+
+.cru-dialog-container-button {
+  margin-left: 1em;
+}
\ No newline at end of file
diff --git a/FrontEnd/src/components/dialog/DialogContainer.tsx b/FrontEnd/src/components/dialog/DialogContainer.tsx
new file mode 100644
index 00000000..afee2669
--- /dev/null
+++ b/FrontEnd/src/components/dialog/DialogContainer.tsx
@@ -0,0 +1,95 @@
+import { ComponentProps, Ref, ReactNode } from "react";
+import classNames from "classnames";
+
+import { ThemeColor, Text, useC } from "../common";
+import { ButtonRow, ButtonRowV2 } from "../button";
+
+import "./DialogContainer.css";
+
+interface DialogContainerBaseProps {
+  className?: string;
+  title: Text;
+  titleColor?: ThemeColor;
+  titleClassName?: string;
+  titleRef?: Ref<HTMLDivElement>;
+  bodyContainerClassName?: string;
+  bodyContainerRef?: Ref<HTMLDivElement>;
+  buttonsClassName?: string;
+  buttonsContainerRef?: ComponentProps<typeof ButtonRow>["containerRef"];
+  children: ReactNode;
+}
+
+interface DialogContainerWithButtonsProps extends DialogContainerBaseProps {
+  buttons: ComponentProps<typeof ButtonRow>["buttons"];
+}
+
+interface DialogContainerWithButtonsV2Props extends DialogContainerBaseProps {
+  buttonsV2: ComponentProps<typeof ButtonRowV2>["buttons"];
+}
+
+type DialogContainerProps =
+  | DialogContainerWithButtonsProps
+  | DialogContainerWithButtonsV2Props;
+
+export default function DialogContainer(props: DialogContainerProps) {
+  const {
+    className,
+    title,
+    titleColor,
+    titleClassName,
+    titleRef,
+    bodyContainerClassName,
+    bodyContainerRef,
+    buttonsClassName,
+    buttonsContainerRef,
+    children,
+  } = props;
+
+  const c = useC();
+
+  return (
+    <div className={classNames(className)}>
+      <div
+        ref={titleRef}
+        className={classNames(
+          `cru-dialog-container-title cru-${titleColor ?? "primary"}`,
+          titleClassName,
+        )}
+      >
+        {c(title)}
+      </div>
+      <hr className="cru-dialog-container-hr" />
+      <div
+        ref={bodyContainerRef}
+        className={classNames(
+          "cru-dialog-container-body",
+          bodyContainerClassName,
+        )}
+      >
+        {children}
+      </div>
+      <hr className="cru-dialog-container-hr" />
+      {"buttons" in props ? (
+        <ButtonRow
+          containerRef={buttonsContainerRef}
+          className={classNames(
+            "cru-dialog-container-button-row",
+            buttonsClassName,
+          )}
+          buttons={props.buttons}
+          buttonsClassName="cru-dialog-container-button"
+        />
+      ) : (
+        <ButtonRowV2
+          containerRef={buttonsContainerRef}
+          className={classNames(
+            "cru-dialog-container-button-row",
+            buttonsClassName,
+          )}
+          buttons={props.buttonsV2}
+          buttonsClassName="cru-dialog-container-button"
+        />
+      )}
+    </div>
+  );
+}
diff --git a/FrontEnd/src/components/dialog/FullPageDialog.css b/FrontEnd/src/components/dialog/FullPageDialog.css
new file mode 100644
index 00000000..2f1fc636
--- /dev/null
+++ b/FrontEnd/src/components/dialog/FullPageDialog.css
@@ -0,0 +1,44 @@
+.cru-full-page {
+  position: fixed;
+  z-index: 1030;
+  left: 0;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  background-color: white;
+  padding-top: 56px;
+}
+
+.cru-full-page-top-bar {
+  height: 56px;
+  position: absolute;
+  top: 0;
+  left: 0;
+  right: 0;
+  z-index: 1;
+  background-color: var(--cru-primary-color);
+  display: flex;
+  align-items: center;
+}
+
+.cru-full-page-content-container {
+  overflow: scroll;
+}
+
+.cru-full-page-back-button {
+  color: var(--cru-primary-t-color);
+}
+
+.cru-full-page-enter {
+  transform: translate(100%, 0);
+}
+
+.cru-full-page-enter-active {
+  transform: none;
+  transition: transform 0.3s;
+}
+
+.cru-full-page-exit-active {
+  transition: transform 0.3s;
+  transform: translate(100%, 0);
+}
diff --git a/FrontEnd/src/components/dialog/FullPageDialog.tsx b/FrontEnd/src/components/dialog/FullPageDialog.tsx
new file mode 100644
index 00000000..6368fc0a
--- /dev/null
+++ b/FrontEnd/src/components/dialog/FullPageDialog.tsx
@@ -0,0 +1,53 @@
+import * as React from "react";
+import { createPortal } from "react-dom";
+import classnames from "classnames";
+import { CSSTransition } from "react-transition-group";
+
+import "./FullPageDialog.css";
+import IconButton from "../button/IconButton";
+
+export interface FullPageDialogProps {
+  show: boolean;
+  onBack: () => void;
+  contentContainerClassName?: string;
+  children: React.ReactNode;
+}
+
+const FullPageDialog: React.FC<FullPageDialogProps> = ({
+  show,
+  onBack,
+  children,
+  contentContainerClassName,
+}) => {
+  return createPortal(
+    <CSSTransition
+      mountOnEnter
+      unmountOnExit
+      in={show}
+      timeout={300}
+      classNames="cru-full-page"
+    >
+      <div className="cru-full-page">
+        <div className="cru-full-page-top-bar">
+          <IconButton
+            icon="arrow-left"
+            className="ms-3 cru-full-page-back-button"
+            onClick={onBack}
+          />
+        </div>
+        <div
+          className={classnames(
+            "cru-full-page-content-container",
+            contentContainerClassName
+          )}
+        >
+          {children}
+        </div>
+      </div>
+    </CSSTransition>,
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    document.getElementById("portal")!
+  );
+};
+
+export default FullPageDialog;
diff --git a/FrontEnd/src/components/dialog/OperationDialog.css b/FrontEnd/src/components/dialog/OperationDialog.css
new file mode 100644
index 00000000..f4b7237e
--- /dev/null
+++ b/FrontEnd/src/components/dialog/OperationDialog.css
@@ -0,0 +1,8 @@
+.cru-operation-dialog-prompt {
+  color: var(--cru-surface-on-color);
+}
+
+.cru-operation-dialog-input-group {
+  display: block;
+  margin: 0.5em 0;
+}
diff --git a/FrontEnd/src/components/dialog/OperationDialog.tsx b/FrontEnd/src/components/dialog/OperationDialog.tsx
new file mode 100644
index 00000000..e5db7f4f
--- /dev/null
+++ b/FrontEnd/src/components/dialog/OperationDialog.tsx
@@ -0,0 +1,230 @@
+import { useState, ReactNode, ComponentProps } from "react";
+import classNames from "classnames";
+
+import { useC, Text, ThemeColor } from "../common";
+
+import {
+  useInputs,
+  InputGroup,
+  Initializer as InputInitializer,
+  InputValueDict,
+  InputErrorDict,
+  InputConfirmValueDict,
+} from "../input";
+import Dialog from "./Dialog";
+import DialogContainer from "./DialogContainer";
+import { ButtonRow } from "../button";
+
+import "./OperationDialog.css";
+
+export type { InputInitializer, InputValueDict, InputErrorDict };
+
+interface OperationDialogPromptProps {
+  message?: Text;
+  customMessage?: Text;
+  customMessageNode?: ReactNode;
+  className?: string;
+}
+
+function OperationDialogPrompt(props: OperationDialogPromptProps) {
+  const { message, customMessage, customMessageNode, className } = props;
+
+  const c = useC();
+
+  return (
+    <div className={classNames(className, "cru-operation-dialog-prompt")}>
+      {message && <p>{c(message)}</p>}
+      {customMessageNode ?? (customMessage != null ? c(customMessage) : null)}
+    </div>
+  );
+}
+
+export interface OperationDialogProps<TData> {
+  open: boolean;
+  onClose: () => void;
+
+  color?: ThemeColor;
+  inputColor?: ThemeColor;
+  title: Text;
+  inputPrompt?: Text;
+  inputPromptNode?: ReactNode;
+  successPrompt?: (data: TData) => Text;
+  successPromptNode?: (data: TData) => ReactNode;
+  failurePrompt?: (error: unknown) => Text;
+  failurePromptNode?: (error: unknown) => ReactNode;
+
+  inputs: InputInitializer;
+
+  onProcess: (inputs: InputConfirmValueDict) => Promise<TData>;
+  onSuccessAndClose?: (data: TData) => void;
+}
+
+function OperationDialog<TData>(props: OperationDialogProps<TData>) {
+  const {
+    open,
+    onClose,
+    color,
+    inputColor,
+    title,
+    inputPrompt,
+    inputPromptNode,
+    successPrompt,
+    successPromptNode,
+    failurePrompt,
+    failurePromptNode,
+    inputs,
+    onProcess,
+    onSuccessAndClose,
+  } = props;
+
+  if (process.env.NODE_ENV === "development") {
+    if (inputPrompt && inputPromptNode) {
+      console.log("InputPrompt and inputPromptNode are both set.");
+    }
+    if (successPrompt && successPromptNode) {
+      console.log("SuccessPrompt and successPromptNode are both set.");
+    }
+    if (failurePrompt && failurePromptNode) {
+      console.log("FailurePrompt and failurePromptNode are both set.");
+    }
+  }
+
+  type Step =
+    | { type: "input" }
+    | { type: "process" }
+    | {
+        type: "success";
+        data: TData;
+      }
+    | {
+        type: "failure";
+        data: unknown;
+      };
+
+  const [step, setStep] = useState<Step>({ type: "input" });
+
+  const { inputGroupProps, hasErrorAndDirty, setAllDisabled, confirm } =
+    useInputs({
+      init: inputs,
+    });
+
+  function close() {
+    if (step.type !== "process") {
+      onClose();
+      if (step.type === "success" && onSuccessAndClose) {
+        onSuccessAndClose?.(step.data);
+      }
+    } else {
+      console.log("Attempt to close modal dialog when processing.");
+    }
+  }
+
+  function onConfirm() {
+    const result = confirm();
+    if (result.type === "ok") {
+      setStep({ type: "process" });
+      setAllDisabled(true);
+      onProcess(result.values).then(
+        (d) => {
+          setStep({
+            type: "success",
+            data: d,
+          });
+        },
+        (e: unknown) => {
+          setStep({
+            type: "failure",
+            data: e,
+          });
+        },
+      );
+    }
+  }
+
+  let body: ReactNode;
+  let buttons: ComponentProps<typeof ButtonRow>["buttons"];
+
+  if (step.type === "input" || step.type === "process") {
+    const isProcessing = step.type === "process";
+
+    body = (
+      <div>
+        <OperationDialogPrompt
+          customMessage={inputPrompt}
+          customMessageNode={inputPromptNode}
+        />
+        <InputGroup
+          containerClassName="cru-operation-dialog-input-group"
+          color={inputColor ?? "primary"}
+          {...inputGroupProps}
+        />
+      </div>
+    );
+    buttons = [
+      {
+        key: "cancel",
+        type: "normal",
+        props: {
+          text: "operationDialog.cancel",
+          color: "secondary",
+          outline: true,
+          onClick: close,
+          disabled: isProcessing,
+        },
+      },
+      {
+        key: "confirm",
+        type: "loading",
+        props: {
+          text: "operationDialog.confirm",
+          color,
+          loading: isProcessing,
+          disabled: hasErrorAndDirty,
+          onClick: onConfirm,
+        },
+      },
+    ];
+  } else {
+    const result = step;
+
+    const promptProps: OperationDialogPromptProps =
+      result.type === "success"
+        ? {
+            message: "operationDialog.success",
+            customMessage: successPrompt?.(result.data),
+            customMessageNode: successPromptNode?.(result.data),
+          }
+        : {
+            message: "operationDialog.error",
+            customMessage: failurePrompt?.(result.data),
+            customMessageNode: failurePromptNode?.(result.data),
+          };
+    body = (
+      <div>
+        <OperationDialogPrompt {...promptProps} />
+      </div>
+    );
+
+    buttons = [
+      {
+        key: "ok",
+        type: "normal",
+        props: {
+          text: "operationDialog.ok",
+          color: "primary",
+          onClick: close,
+        },
+      },
+    ];
+  }
+
+  return (
+    <Dialog open={open} onClose={close}>
+      <DialogContainer title={title} titleColor={color} buttons={buttons}>
+        {body}
+      </DialogContainer>
+    </Dialog>
+  );
+}
+
+export default OperationDialog;
diff --git a/FrontEnd/src/components/dialog/index.ts b/FrontEnd/src/components/dialog/index.ts
new file mode 100644
index 00000000..59f15791
--- /dev/null
+++ b/FrontEnd/src/components/dialog/index.ts
@@ -0,0 +1,64 @@
+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[],
+  options?: {
+    initDialog?: D | null;
+    onClose?: {
+      [K in D]?: () => void;
+    };
+  },
+): {
+  dialog: D | null;
+  switchDialog: (newDialog: D | null) => void;
+  dialogPropsMap: DialogPropsMap<D>;
+  createDialogSwitch: (newDialog: D | null) => () => void;
+} {
+  const [dialog, setDialog] = useState<D | null>(options?.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);
+            options?.onClose?.[d]?.();
+          },
+        },
+      ]),
+    ) as DialogPropsMap<D>,
+    createDialogSwitch: (newDialog: D | null) => () => switchDialog(newDialog),
+  };
+}
diff --git a/FrontEnd/src/components/hooks.ts b/FrontEnd/src/components/hooks.ts
new file mode 100644
index 00000000..523a4538
--- /dev/null
+++ b/FrontEnd/src/components/hooks.ts
@@ -0,0 +1,14 @@
+// TODO: Migrate hooks
+
+export {
+  useIsSmallScreen,
+  useClickOutside,
+  useScrollToBottom,
+} from "~src/utilities/hooks";
+
+import { useMediaQuery } from "react-responsive";
+import { breakpoints } from "./breakpoints";
+
+export function useMobile(): boolean {
+  return useMediaQuery({ maxWidth: breakpoints.sm });
+}
diff --git a/FrontEnd/src/components/index.css b/FrontEnd/src/components/index.css
new file mode 100644
index 00000000..a8f5e9a5
--- /dev/null
+++ b/FrontEnd/src/components/index.css
@@ -0,0 +1,100 @@
+@import "./theme.css";
+
+* {
+  box-sizing: border-box;
+  margin-inline: 0;
+  margin-block: 0;
+}
+
+body {
+  font-family: var(--cru-default-font-family);
+  background: var(--cru-body-background-color);
+  color: var(--cru-text-primary-color);
+  line-height: 1.2;
+}
+
+.cru-page {
+  padding: var(--cru-page-padding);
+}
+
+.cru-page-no-top-padding {
+  padding-top: 0;
+}
+
+.cru-text-center {
+  text-align: center;
+}
+
+.cru-text-end {
+  text-align: end;
+}
+
+.cru-float-left {
+  float: left;
+}
+
+.cru-float-right {
+  float: right;
+}
+
+.cru-align-text-bottom {
+  vertical-align: text-bottom;
+}
+
+.cru-align-middle {
+  vertical-align: middle;
+}
+
+.cru-clearfix::after {
+  clear: both;
+}
+
+.cru-fill-parent {
+  width: 100%;
+  height: 100%;
+}
+
+.cru-avatar {
+  width: 60px;
+  height: 60px;
+}
+
+.cru-avatar.large {
+  width: 100px;
+  height: 100px;
+}
+
+.cru-avatar.small {
+  width: 40px;
+  height: 40px;
+}
+
+.cru-round {
+  border-radius: 50%;
+}
+
+.cru-tab-pages-action-area {
+  display: flex;
+  align-items: center;
+}
+
+.alert-container {
+  position: fixed;
+  z-index: 1070;
+}
+
+@media (min-width: 576px) {
+  .alert-container {
+    bottom: 0;
+    right: 0;
+  }
+}
+
+@media (max-width: 575.98px) {
+  .alert-container {
+    bottom: 0;
+    right: 0;
+    left: 0;
+    text-align: center;
+  }
+}
\ No newline at end of file
diff --git a/FrontEnd/src/components/input/InputGroup.css b/FrontEnd/src/components/input/InputGroup.css
new file mode 100644
index 00000000..7e905b1e
--- /dev/null
+++ b/FrontEnd/src/components/input/InputGroup.css
@@ -0,0 +1,54 @@
+.cru-input-group {
+  display: block;
+}
+
+.cru-input-container {
+  margin: 0.4em 0;
+}
+
+.cru-input-label {
+  display: block;
+  color: var(--cru-clickable-normal-color);
+  font-size: 0.9em;
+  margin-bottom: 0.3em;
+}
+
+.cru-input-label-inline {
+  margin-inline-start: 0.5em;
+}
+
+.cru-input-type-text input {
+  appearance: none;
+  display: block;
+  border: 1px solid;
+  /* color: var(--cru-surface-on-color); */
+  /* background-color: var(--cru-surface-color); */
+  margin: 0;
+  font-size: 1em;
+  padding: 0.2em;
+}
+
+.cru-input-type-text input:hover {
+  border-color: var(--cru-clickable-hover-color);
+}
+
+.cru-input-type-text input:focus {
+  border-color: var(--cru-clickable-focus-color);
+}
+
+.cru-input-type-text input:disabled {
+  border-color: var(--cru-clickable-disabled-color);
+}
+
+.cru-input-error {
+  display: block;
+  font-size: 0.8em;
+  color: var(--cru-danger-color);
+  margin-top: 0.4em;
+}
+
+.cru-input-helper {
+  display: block;
+  font-size: 0.8em;
+  color: var(--cru-primary-color);
+}
\ No newline at end of file
diff --git a/FrontEnd/src/components/input/InputGroup.tsx b/FrontEnd/src/components/input/InputGroup.tsx
new file mode 100644
index 00000000..4f487344
--- /dev/null
+++ b/FrontEnd/src/components/input/InputGroup.tsx
@@ -0,0 +1,463 @@
+/**
+ * Some notes for InputGroup:
+ * This is one of the most complicated components in this project.
+ * Probably because the feature is complex and involved user inputs.
+ *
+ * I hope it contains following features:
+ * - Input features
+ *   - Supports a wide range of input types.
+ *   - Validator to validate user inputs.
+ *   - Can set initial values.
+ *   - Dirty, aka, has user touched this input.
+ * - Developer friendly
+ *   - Easy to use APIs.
+ *   - Type check as much as possible.
+ * - UI
+ *   - Configurable appearance.
+ *   - Can display helper and error messages.
+ * - Easy to extend, like new input types.
+ *
+ * So here is some design decisions:
+ * Inputs are identified by its _key_.
+ * `InputGroup` component takes care of only UI and no logic.
+ * `useInputs` hook takes care of logic and generate props for `InputGroup`.
+ */
+
+import { useState, Ref, useId } from "react";
+import classNames from "classnames";
+
+import { useC, Text, ThemeColor } from "../common";
+
+import "./InputGroup.css";
+
+export interface InputBase {
+  key: string;
+  label: Text;
+  helper?: Text;
+  disabled?: boolean;
+  error?: Text;
+}
+
+export interface TextInput extends InputBase {
+  type: "text";
+  value: string;
+  password?: boolean;
+}
+
+export interface BoolInput extends InputBase {
+  type: "bool";
+  value: boolean;
+}
+
+export interface SelectInputOption {
+  value: string;
+  label: Text;
+  icon?: string;
+}
+
+export interface SelectInput extends InputBase {
+  type: "select";
+  value: string;
+  options: SelectInputOption[];
+}
+
+export type Input = TextInput | BoolInput | SelectInput;
+
+export type InputValue = Input["value"];
+
+export type InputValueDict = Record<string, InputValue>;
+export type InputErrorDict = Record<string, Text>;
+export type InputDisabledDict = Record<string, boolean>;
+export type InputDirtyDict = Record<string, boolean>;
+// use never so you don't have to cast everywhere
+export type InputConfirmValueDict = Record<string, never>;
+
+export type GeneralInputErrorDict =
+  | {
+      [key: string]: Text | null | undefined;
+    }
+  | null
+  | undefined;
+
+type MakeInputInfo<I extends Input> = Omit<I, "value" | "error" | "disabled">;
+
+export type InputInfo = {
+  [I in Input as I["type"]]: MakeInputInfo<I>;
+}[Input["type"]];
+
+export type Validator = (
+  values: InputValueDict,
+  inputs: InputInfo[],
+) => GeneralInputErrorDict;
+
+export type InputScheme = {
+  inputs: InputInfo[];
+  validator?: Validator;
+};
+
+export type InputData = {
+  values: InputValueDict;
+  errors: InputErrorDict;
+  disabled: InputDisabledDict;
+  dirties: InputDirtyDict;
+};
+
+export type State = {
+  scheme: InputScheme;
+  data: InputData;
+};
+
+export type DataInitialization = {
+  values?: InputValueDict;
+  errors?: GeneralInputErrorDict;
+  disabled?: InputDisabledDict;
+  dirties?: InputDirtyDict;
+};
+
+export type Initialization = {
+  scheme: InputScheme;
+  dataInit?: DataInitialization;
+};
+
+export type GeneralInitialization = Initialization | InputScheme | InputInfo[];
+
+export type Initializer = GeneralInitialization | (() => GeneralInitialization);
+
+export interface InputGroupProps {
+  color?: ThemeColor;
+  containerClassName?: string;
+  containerRef?: Ref<HTMLDivElement>;
+
+  inputs: Input[];
+  onChange: (index: number, value: Input["value"]) => void;
+}
+
+function cleanObject<V>(o: Record<string, V>): Record<string, NonNullable<V>> {
+  const result = { ...o };
+  for (const key of Object.keys(result)) {
+    if (result[key] == null) {
+      delete result[key];
+    }
+  }
+  return result as never;
+}
+
+export type ConfirmResult =
+  | {
+      type: "ok";
+      values: InputConfirmValueDict;
+    }
+  | {
+      type: "error";
+      errors: InputErrorDict;
+    };
+
+function validate(
+  validator: Validator | null | undefined,
+  values: InputValueDict,
+  inputs: InputInfo[],
+): InputErrorDict {
+  return cleanObject(validator?.(values, inputs) ?? {});
+}
+
+export function useInputs(options: { init: Initializer }): {
+  inputGroupProps: InputGroupProps;
+  hasError: boolean;
+  hasErrorAndDirty: boolean;
+  confirm: () => ConfirmResult;
+  setAllDisabled: (disabled: boolean) => void;
+} {
+  function initializeValue(
+    input: InputInfo,
+    value?: InputValue | null,
+  ): InputValue {
+    if (input.type === "text") {
+      return value ?? "";
+    } else if (input.type === "bool") {
+      return value ?? false;
+    } else if (input.type === "select") {
+      return value ?? input.options[0].value;
+    }
+    throw new Error("Unknown input type");
+  }
+
+  function initialize(generalInitialization: GeneralInitialization): State {
+    const initialization: Initialization = Array.isArray(generalInitialization)
+      ? { scheme: { inputs: generalInitialization } }
+      : "scheme" in generalInitialization
+      ? generalInitialization
+      : { scheme: generalInitialization };
+
+    const { scheme, dataInit } = initialization;
+    const { inputs, validator } = scheme;
+    const keys = inputs.map((input) => input.key);
+
+    if (process.env.NODE_ENV === "development") {
+      const checkKeys = (dict: Record<string, unknown> | undefined) => {
+        if (dict != null) {
+          for (const key of Object.keys(dict)) {
+            if (!keys.includes(key)) {
+              console.warn("");
+            }
+          }
+        }
+      };
+
+      checkKeys(dataInit?.values);
+      checkKeys(dataInit?.errors ?? {});
+      checkKeys(dataInit?.disabled);
+      checkKeys(dataInit?.dirties);
+    }
+
+    function clean<V>(
+      dict: Record<string, V> | null | undefined,
+    ): Record<string, NonNullable<V>> {
+      return dict != null ? cleanObject(dict) : {};
+    }
+
+    const values: InputValueDict = {};
+    const disabled: InputDisabledDict = clean(dataInit?.disabled);
+    const dirties: InputDirtyDict = clean(dataInit?.dirties);
+    const isErrorSet = dataInit?.errors != null;
+    let errors: InputErrorDict = clean(dataInit?.errors);
+
+    for (let i = 0; i < inputs.length; i++) {
+      const input = inputs[i];
+      const { key } = input;
+
+      values[key] = initializeValue(input, dataInit?.values?.[key]);
+    }
+
+    if (isErrorSet) {
+      if (process.env.NODE_ENV === "development") {
+        console.log(
+          "You explicitly set errors (not undefined) in initializer, so validator won't run.",
+        );
+      }
+    } else {
+      errors = validate(validator, values, inputs);
+    }
+
+    return {
+      scheme,
+      data: {
+        values,
+        errors,
+        disabled,
+        dirties,
+      },
+    };
+  }
+
+  const { init } = options;
+  const initializer = typeof init === "function" ? init : () => init;
+
+  const [state, setState] = useState<State>(() => initialize(initializer()));
+
+  const { scheme, data } = state;
+  const { validator } = scheme;
+
+  function createAllBooleanDict(value: boolean): Record<string, boolean> {
+    const result: InputDirtyDict = {};
+    for (const key of scheme.inputs.map((input) => input.key)) {
+      result[key] = value;
+    }
+    return result;
+  }
+
+  const createAllDirties = () => createAllBooleanDict(true);
+
+  const componentInputs: Input[] = [];
+
+  for (let i = 0; i < scheme.inputs.length; i++) {
+    const input = scheme.inputs[i];
+    const value = data.values[input.key];
+    const error = data.errors[input.key];
+    const disabled = data.disabled[input.key] ?? false;
+    const dirty = data.dirties[input.key] ?? false;
+    const componentInput: Input = {
+      ...input,
+      value: value as never,
+      disabled,
+      error: dirty ? error : undefined,
+    };
+    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,
+      onChange: (index, value) => {
+        const input = scheme.inputs[index];
+        const { key } = input;
+        const newValues = { ...data.values, [key]: value };
+        const newDirties = { ...data.dirties, [key]: true };
+        const newErrors = validate(validator, newValues, scheme.inputs);
+        setState({
+          scheme,
+          data: {
+            ...data,
+            values: newValues,
+            errors: newErrors,
+            dirties: newDirties,
+          },
+        });
+      },
+    },
+    hasError,
+    hasErrorAndDirty: hasError && hasDirty,
+    confirm() {
+      const newDirties = createAllDirties();
+      const newErrors = validate(validator, data.values, scheme.inputs);
+
+      setState({
+        scheme,
+        data: {
+          ...data,
+          dirties: newDirties,
+          errors: newErrors,
+        },
+      });
+
+      if (Object.keys(newErrors).length !== 0) {
+        return {
+          type: "error",
+          errors: newErrors,
+        };
+      } else {
+        return {
+          type: "ok",
+          values: data.values as InputConfirmValueDict,
+        };
+      }
+    },
+    setAllDisabled(disabled: boolean) {
+      setState({
+        scheme,
+        data: {
+          ...data,
+          disabled: createAllBooleanDict(disabled),
+        },
+      });
+    },
+  };
+}
+
+export function InputGroup({
+  color,
+  inputs,
+  onChange,
+  containerRef,
+  containerClassName,
+}: InputGroupProps) {
+  const c = useC();
+
+  const id = useId();
+
+  return (
+    <div
+      ref={containerRef}
+      className={classNames(
+        "cru-input-group",
+        `cru-clickable-${color ?? "primary"}`,
+        containerClassName,
+      )}
+    >
+      {inputs.map((item, index) => {
+        const { key, type, value, label, error, helper, disabled } = item;
+
+        const getContainerClassName = (
+          ...additionalClassNames: classNames.ArgumentArray
+        ) =>
+          classNames(
+            `cru-input-container cru-input-type-${type}`,
+            error && "error",
+            ...additionalClassNames,
+          );
+
+        const changeValue = (value: InputValue) => {
+          onChange(index, value);
+        };
+
+        const inputId = `${id}-${key}`;
+
+        if (type === "text") {
+          const { password } = item;
+          return (
+            <div
+              key={key}
+              className={getContainerClassName(password && "password")}
+            >
+              {label && (
+                <label className="cru-input-label" htmlFor={inputId}>
+                  {c(label)}
+                </label>
+              )}
+              <input
+                id={inputId}
+                type={password ? "password" : "text"}
+                value={value}
+                onChange={(event) => {
+                  const v = event.target.value;
+                  changeValue(v);
+                }}
+                disabled={disabled}
+              />
+              {error && <div className="cru-input-error">{c(error)}</div>}
+              {helper && <div className="cru-input-helper">{c(helper)}</div>}
+            </div>
+          );
+        } else if (type === "bool") {
+          return (
+            <div key={key} className={getContainerClassName()}>
+              <input
+                id={inputId}
+                type="checkbox"
+                checked={value}
+                onChange={(event) => {
+                  const v = event.currentTarget.checked;
+                  changeValue(v);
+                }}
+                disabled={disabled}
+              />
+              <label className="cru-input-label-inline" htmlFor={inputId}>
+                {c(label)}
+              </label>
+              {error && <div className="cru-input-error">{c(error)}</div>}
+              {helper && <div className="cru-input-helper">{c(helper)}</div>}
+            </div>
+          );
+        } else if (type === "select") {
+          return (
+            <div key={key} className={getContainerClassName()}>
+              <label className="cru-input-label" htmlFor={inputId}>
+                {c(label)}
+              </label>
+              <select
+                id={inputId}
+                value={value}
+                onChange={(event) => {
+                  const e = event.target.value;
+                  changeValue(e);
+                }}
+                disabled={disabled}
+              >
+                {item.options.map((option) => {
+                  return (
+                    <option value={option.value} key={option.value}>
+                      {option.icon}
+                      {c(option.label)}
+                    </option>
+                  );
+                })}
+              </select>
+            </div>
+          );
+        }
+      })}
+    </div>
+  );
+}
diff --git a/FrontEnd/src/components/input/index.ts b/FrontEnd/src/components/input/index.ts
new file mode 100644
index 00000000..ca183089
--- /dev/null
+++ b/FrontEnd/src/components/input/index.ts
@@ -0,0 +1,11 @@
+export { useInputs, InputGroup } from "./InputGroup";
+
+export type {
+  InputValueDict,
+  InputErrorDict,
+  InputDirtyDict,
+  InputDisabledDict,
+  InputConfirmValueDict,
+  Validator,
+  Initializer,
+} from "./InputGroup";
diff --git a/FrontEnd/src/components/list/ListContainer.css b/FrontEnd/src/components/list/ListContainer.css
new file mode 100644
index 00000000..53781834
--- /dev/null
+++ b/FrontEnd/src/components/list/ListContainer.css
@@ -0,0 +1,4 @@
+.cru-list-container {
+  border: 1px solid var(--cru-clickable-primary-normal-color);
+  border-radius: 5px;
+}
diff --git a/FrontEnd/src/components/list/ListContainer.tsx b/FrontEnd/src/components/list/ListContainer.tsx
new file mode 100644
index 00000000..aa00d12c
--- /dev/null
+++ b/FrontEnd/src/components/list/ListContainer.tsx
@@ -0,0 +1,23 @@
+import { ComponentPropsWithoutRef, forwardRef, Ref } from "react";
+import classNames from "classnames";
+
+import "./ListContainer.css"
+
+function _ListContainer(
+  { className, children, ...otherProps }: ComponentPropsWithoutRef<"div">,
+  ref: Ref<HTMLDivElement>,
+) {
+  return (
+    <div
+      ref={ref}
+      className={classNames("cru-list-container", className)}
+      {...otherProps}
+    >
+      {children}
+    </div>
+  );
+}
+
+const ListContainer = forwardRef(_ListContainer);
+
+export default ListContainer;
diff --git a/FrontEnd/src/components/list/ListItemContainer.css b/FrontEnd/src/components/list/ListItemContainer.css
new file mode 100644
index 00000000..8d7afa9f
--- /dev/null
+++ b/FrontEnd/src/components/list/ListItemContainer.css
@@ -0,0 +1,3 @@
+.cru-list-item-container {
+  border: 1px solid var(--cru-clickable-primary-normal-color);
+}
diff --git a/FrontEnd/src/components/list/ListItemContainer.tsx b/FrontEnd/src/components/list/ListItemContainer.tsx
new file mode 100644
index 00000000..315cbd6e
--- /dev/null
+++ b/FrontEnd/src/components/list/ListItemContainer.tsx
@@ -0,0 +1,23 @@
+import { ComponentPropsWithoutRef, forwardRef, Ref } from "react";
+import classNames from "classnames";
+
+import "./ListItemContainer.css";
+
+function _ListItemContainer(
+  { className, children, ...otherProps }: ComponentPropsWithoutRef<"div">,
+  ref: Ref<HTMLDivElement>,
+) {
+  return (
+    <div
+      ref={ref}
+      className={classNames("cru-list-item-container", className)}
+      {...otherProps}
+    >
+      {children}
+    </div>
+  );
+}
+
+const ListItemContainer = forwardRef(_ListItemContainer);
+
+export default ListItemContainer;
diff --git a/FrontEnd/src/components/list/index.ts b/FrontEnd/src/components/list/index.ts
new file mode 100644
index 00000000..e183f7da
--- /dev/null
+++ b/FrontEnd/src/components/list/index.ts
@@ -0,0 +1,4 @@
+import ListContainer from "./ListContainer";
+import ListItemContainer from "./ListItemContainer";
+
+export { ListContainer, ListItemContainer };
diff --git a/FrontEnd/src/components/menu/Menu.css b/FrontEnd/src/components/menu/Menu.css
new file mode 100644
index 00000000..75734533
--- /dev/null
+++ b/FrontEnd/src/components/menu/Menu.css
@@ -0,0 +1,36 @@
+.cru-menu {
+  min-width: 200px;
+}
+
+.cru-menu-item {
+  display: block;
+  font-size: 1em;
+  width: 100%;
+  padding: 0.5em 1.5em;
+  transition: all 0.5s;
+  color: var(--cru-clickable-normal-color);
+  background-color: var(--cru-clickable-grayscale-normal-color);
+  border: none;
+  cursor: pointer;
+}
+
+.cru-menu-item:hover {
+  background-color: var(--cru-clickable-grayscale-hover-color);
+}
+
+.cru-menu-item:focus {
+  background-color: var(--cru-clickable-grayscale-focus-color);
+}
+
+.cru-menu-item:active {
+  background-color: var(--cru-clickable-grayscale-active-color);
+}
+
+.cru-menu-item-icon {
+  margin-right: 1em;
+}
+
+.cru-menu-divider {
+  border-width: 0;
+  border-top: 1px solid var(--cru-primary-color);
+}
\ No newline at end of file
diff --git a/FrontEnd/src/components/menu/Menu.tsx b/FrontEnd/src/components/menu/Menu.tsx
new file mode 100644
index 00000000..e8099c76
--- /dev/null
+++ b/FrontEnd/src/components/menu/Menu.tsx
@@ -0,0 +1,62 @@
+import { CSSProperties } from "react";
+import classNames from "classnames";
+
+import { useC, Text, ThemeColor } from "../common";
+
+import "./Menu.css";
+import Icon from "../Icon";
+
+export type MenuItem =
+  | {
+      type: "divider";
+    }
+  | {
+      type: "button";
+      text: Text;
+      icon?: string;
+      color?: ThemeColor;
+      onClick: () => void;
+    };
+
+export type MenuItems = MenuItem[];
+
+export type MenuProps = {
+  items: MenuItems;
+  onItemClicked?: () => void;
+  className?: string;
+  style?: CSSProperties;
+};
+
+export default function Menu({
+  items,
+  onItemClicked,
+  className,
+  style,
+}: MenuProps) {
+  const c = useC();
+
+  return (
+    <div className={classNames("cru-menu", className)} style={style}>
+      {items.map((item, index) => {
+        if (item.type === "divider") {
+          return <hr key={index} className="cru-menu-divider" />;
+        } else {
+          const { text, color, icon, onClick } = item;
+          return (
+            <button
+              key={index}
+              className={`cru-menu-item cru-clickable-${color ?? "primary"}`}
+              onClick={() => {
+                onClick();
+                onItemClicked?.();
+              }}
+            >
+              {icon != null && <Icon color={color} icon={icon} />}
+              {c(text)}
+            </button>
+          );
+        }
+      })}
+    </div>
+  );
+}
diff --git a/FrontEnd/src/components/menu/PopupMenu.css b/FrontEnd/src/components/menu/PopupMenu.css
new file mode 100644
index 00000000..149e0699
--- /dev/null
+++ b/FrontEnd/src/components/menu/PopupMenu.css
@@ -0,0 +1,7 @@
+.cru-popup-menu-menu-container {
+  z-index: 1040;
+  border-radius: 3px;
+  border: var(--cru-clickable-normal-color) 1.5px solid;
+  background-color: var(--cru-background-color);
+  overflow: hidden;
+}
diff --git a/FrontEnd/src/components/menu/PopupMenu.tsx b/FrontEnd/src/components/menu/PopupMenu.tsx
new file mode 100644
index 00000000..23a67f79
--- /dev/null
+++ b/FrontEnd/src/components/menu/PopupMenu.tsx
@@ -0,0 +1,73 @@
+import { useState, CSSProperties, ReactNode } from "react";
+import classNames from "classnames";
+import { createPortal } from "react-dom";
+import { usePopper } from "react-popper";
+
+import { useClickOutside } from "~src/utilities/hooks";
+
+import Menu, { MenuItems } from "./Menu";
+
+import { ThemeColor } from "../common";
+
+import "./PopupMenu.css";
+
+export interface PopupMenuProps {
+  color?: ThemeColor;
+  items: MenuItems;
+  children?: ReactNode;
+  containerClassName?: string;
+  containerStyle?: CSSProperties;
+}
+
+export default function PopupMenu({
+  color,
+  items,
+  children,
+  containerClassName,
+  containerStyle,
+}: PopupMenuProps) {
+  const [show, setShow] = useState<boolean>(false);
+
+  const [referenceElement, setReferenceElement] =
+    useState<HTMLDivElement | null>(null);
+  const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(
+    null,
+  );
+  const { styles, attributes } = usePopper(referenceElement, popperElement);
+
+  useClickOutside(popperElement, () => setShow(false), true);
+
+  return (
+    <div
+      ref={setReferenceElement}
+      className={classNames(
+        "cru-popup-menu-trigger-container",
+        containerClassName,
+      )}
+      style={containerStyle}
+      onClick={() => setShow(true)}
+    >
+      {children}
+      {show &&
+        createPortal(
+          <div
+            ref={setPopperElement}
+            className={`cru-popup-menu-menu-container cru-clickable-${
+              color ?? "primary"
+            }`}
+            style={styles.popper}
+            {...attributes.popper}
+          >
+            <Menu
+              items={items}
+              onItemClicked={() => {
+                setShow(false);
+              }}
+            />
+          </div>,
+          // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+          document.getElementById("portal")!,
+        )}
+    </div>
+  );
+}
diff --git a/FrontEnd/src/components/tab/TabPages.tsx b/FrontEnd/src/components/tab/TabPages.tsx
new file mode 100644
index 00000000..6a5f4469
--- /dev/null
+++ b/FrontEnd/src/components/tab/TabPages.tsx
@@ -0,0 +1,71 @@
+import * as React from "react";
+
+import { I18nText, UiLogicError } from "~src/common";
+
+import Tabs from "./Tabs";
+
+export interface TabPage {
+  name: string;
+  text: I18nText;
+  page: React.ReactNode;
+}
+
+export interface TabPagesProps {
+  pages: TabPage[];
+  actions?: React.ReactNode;
+  dense?: boolean;
+  className?: string;
+  style?: React.CSSProperties;
+  navClassName?: string;
+  navStyle?: React.CSSProperties;
+  pageContainerClassName?: string;
+  pageContainerStyle?: React.CSSProperties;
+}
+
+const TabPages: React.FC<TabPagesProps> = ({
+  pages,
+  actions,
+  dense,
+  className,
+  style,
+  navClassName,
+  navStyle,
+  pageContainerClassName,
+  pageContainerStyle,
+}) => {
+  if (pages.length === 0) {
+    throw new UiLogicError("Page list can't be empty.");
+  }
+
+  const [tab, setTab] = React.useState<string>(pages[0].name);
+
+  const currentPage = pages.find((p) => p.name === tab);
+
+  if (currentPage == null) {
+    throw new UiLogicError("Current tab value is bad.");
+  }
+
+  return (
+    <div className={className} style={style}>
+      <Tabs
+        tabs={pages.map((page) => ({
+          name: page.name,
+          text: page.text,
+          onClick: () => {
+            setTab(page.name);
+          },
+        }))}
+        dense={dense}
+        activeTabName={tab}
+        className={navClassName}
+        style={navStyle}
+        actions={actions}
+      />
+      <div className={pageContainerClassName} style={pageContainerStyle}>
+        {currentPage.page}
+      </div>
+    </div>
+  );
+};
+
+export default TabPages;
diff --git a/FrontEnd/src/components/tab/Tabs.css b/FrontEnd/src/components/tab/Tabs.css
new file mode 100644
index 00000000..395d16a7
--- /dev/null
+++ b/FrontEnd/src/components/tab/Tabs.css
@@ -0,0 +1,33 @@
+.cru-nav {
+  border-bottom: var(--cru-primary-color) 1px solid;
+  display: flex;
+}
+
+.cru-nav-item {
+  color: var(--cru-primary-color);
+  border: var(--cru-background-2-color) 0.5px solid;
+  border-bottom: none;
+  padding: 0.5em 1.5em;
+  border-top-left-radius: 5px;
+  border-top-right-radius: 5px;
+  transition: all 0.5s;
+  cursor: pointer;
+}
+
+.cru-nav.dense .cru-nav-item {
+  padding: 0.2em 1em;
+}
+
+.cru-nav-item:hover {
+  background-color: var(--cru-background-1-color);
+}
+
+.cru-nav-item.active {
+  color: var(--cru-primary-t-color);
+  background-color: var(--cru-primary-color);
+  border-color: var(--cru-primary-color);
+}
+
+.cru-nav-action-area {
+  margin-left: auto;
+}
diff --git a/FrontEnd/src/components/tab/Tabs.tsx b/FrontEnd/src/components/tab/Tabs.tsx
new file mode 100644
index 00000000..dc8d9c01
--- /dev/null
+++ b/FrontEnd/src/components/tab/Tabs.tsx
@@ -0,0 +1,62 @@
+import * as React from "react";
+import { Link } from "react-router-dom";
+import { useTranslation } from "react-i18next";
+import classnames from "classnames";
+
+import { convertI18nText, I18nText } from "~src/common";
+
+import "./Tabs.css";
+
+export interface Tab {
+  name: string;
+  text: I18nText;
+  link?: string;
+  onClick?: () => void;
+}
+
+export interface TabsProps {
+  activeTabName?: string;
+  actions?: React.ReactNode;
+  dense?: boolean;
+  tabs: Tab[];
+  className?: string;
+  style?: React.CSSProperties;
+}
+
+export default function Tabs(props: TabsProps): React.ReactElement | null {
+  const { tabs, activeTabName, className, style, dense, actions } = props;
+
+  const { t } = useTranslation();
+
+  return (
+    <div
+      className={classnames("cru-nav", dense && "dense", className)}
+      style={style}
+    >
+      {tabs.map((tab) => {
+        const active = activeTabName === tab.name;
+        const className = classnames("cru-nav-item", active && "active");
+
+        if (tab.link != null) {
+          return (
+            <Link
+              key={tab.name}
+              to={tab.link}
+              onClick={tab.onClick}
+              className={className}
+            >
+              {convertI18nText(tab.text, t)}
+            </Link>
+          );
+        } else {
+          return (
+            <span key={tab.name} onClick={tab.onClick} className={className}>
+              {convertI18nText(tab.text, t)}
+            </span>
+          );
+        }
+      })}
+      <div className="cru-nav-action-area">{actions}</div>
+    </div>
+  );
+}
diff --git a/FrontEnd/src/components/theme-color.css b/FrontEnd/src/components/theme-color.css
new file mode 100644
index 00000000..24a7e267
--- /dev/null
+++ b/FrontEnd/src/components/theme-color.css
@@ -0,0 +1,173 @@
+/* Generated by theme-generator.ts */
+
+:root {
+  --cru-primary-color: hsl(210 100% 40%);
+  --cru-primary-1-color: hsl(210 100% 37%);
+  --cru-primary-2-color: hsl(210 100% 34%);
+  --cru-primary-on-color: hsl(210 100% 100%);
+  --cru-primary-container-color: hsl(210 100% 90%);
+  --cru-primary-container-1-color: hsl(210 100% 80%);
+  --cru-primary-container-2-color: hsl(210 100% 70%);
+  --cru-primary-on-container-color: hsl(210 100% 10%);
+  --cru-secondary-color: hsl(40 100% 40%);
+  --cru-secondary-1-color: hsl(40 100% 37%);
+  --cru-secondary-2-color: hsl(40 100% 34%);
+  --cru-secondary-on-color: hsl(40 100% 100%);
+  --cru-secondary-container-color: hsl(40 100% 90%);
+  --cru-secondary-container-1-color: hsl(40 100% 80%);
+  --cru-secondary-container-2-color: hsl(40 100% 70%);
+  --cru-secondary-on-container-color: hsl(40 100% 10%);
+  --cru-tertiary-color: hsl(160 100% 40%);
+  --cru-tertiary-1-color: hsl(160 100% 37%);
+  --cru-tertiary-2-color: hsl(160 100% 34%);
+  --cru-tertiary-on-color: hsl(160 100% 100%);
+  --cru-tertiary-container-color: hsl(160 100% 90%);
+  --cru-tertiary-container-1-color: hsl(160 100% 80%);
+  --cru-tertiary-container-2-color: hsl(160 100% 70%);
+  --cru-tertiary-on-container-color: hsl(160 100% 10%);
+  --cru-danger-color: hsl(0 100% 40%);
+  --cru-danger-1-color: hsl(0 100% 37%);
+  --cru-danger-2-color: hsl(0 100% 34%);
+  --cru-danger-on-color: hsl(0 100% 100%);
+  --cru-danger-container-color: hsl(0 100% 90%);
+  --cru-danger-container-1-color: hsl(0 100% 80%);
+  --cru-danger-container-2-color: hsl(0 100% 70%);
+  --cru-danger-on-container-color: hsl(0 100% 10%);
+  --cru-success-color: hsl(120 60% 40%);
+  --cru-success-1-color: hsl(120 60% 37%);
+  --cru-success-2-color: hsl(120 60% 34%);
+  --cru-success-on-color: hsl(120 60% 100%);
+  --cru-success-container-color: hsl(120 60% 90%);
+  --cru-success-container-1-color: hsl(120 60% 80%);
+  --cru-success-container-2-color: hsl(120 60% 70%);
+  --cru-success-on-container-color: hsl(120 60% 10%);
+  --cru-surface-dim-color: hsl(0 0% 87%);
+  --cru-surface-color: hsl(0 0% 98%);
+  --cru-surface-1-color: hsl(0 0% 90%);
+  --cru-surface-2-color: hsl(0 0% 82%);
+  --cru-surface-bright-color: hsl(0 0% 98%);
+  --cru-surface-container-lowest-color: hsl(0 0% 100%);
+  --cru-surface-container-low-color: hsl(0 0% 96%);
+  --cru-surface-container-color: hsl(0 0% 94%);
+  --cru-surface-container-high-color: hsl(0 0% 92%);
+  --cru-surface-container-highest-color: hsl(0 0% 90%);
+  --cru-surface-on-color: hsl(0 0% 10%);
+  --cru-surface-on-variant-color: hsl(0 0% 30%);
+  --cru-surface-outline-color: hsl(0 0% 50%);
+  --cru-surface-outline-variant-color: hsl(0 0% 80%);
+}
+
+@media (prefers-color-scheme: dark) {
+  :root {
+    --cru-primary-color: hsl(210 100% 80%);
+    --cru-primary-1-color: hsl(210 100% 75%);
+    --cru-primary-2-color: hsl(210 100% 68%);
+    --cru-primary-on-color: hsl(210 100% 20%);
+    --cru-primary-container-color: hsl(210 100% 30%);
+    --cru-primary-container-1-color: hsl(210 100% 25%);
+    --cru-primary-container-2-color: hsl(210 100% 20%);
+    --cru-primary-on-container-color: hsl(210 100% 90%);
+    --cru-secondary-color: hsl(40 100% 80%);
+    --cru-secondary-1-color: hsl(40 100% 75%);
+    --cru-secondary-2-color: hsl(40 100% 68%);
+    --cru-secondary-on-color: hsl(40 100% 20%);
+    --cru-secondary-container-color: hsl(40 100% 30%);
+    --cru-secondary-container-1-color: hsl(40 100% 25%);
+    --cru-secondary-container-2-color: hsl(40 100% 20%);
+    --cru-secondary-on-container-color: hsl(40 100% 90%);
+    --cru-tertiary-color: hsl(160 100% 80%);
+    --cru-tertiary-1-color: hsl(160 100% 75%);
+    --cru-tertiary-2-color: hsl(160 100% 68%);
+    --cru-tertiary-on-color: hsl(160 100% 20%);
+    --cru-tertiary-container-color: hsl(160 100% 30%);
+    --cru-tertiary-container-1-color: hsl(160 100% 25%);
+    --cru-tertiary-container-2-color: hsl(160 100% 20%);
+    --cru-tertiary-on-container-color: hsl(160 100% 90%);
+    --cru-danger-color: hsl(0 100% 80%);
+    --cru-danger-1-color: hsl(0 100% 75%);
+    --cru-danger-2-color: hsl(0 100% 68%);
+    --cru-danger-on-color: hsl(0 100% 20%);
+    --cru-danger-container-color: hsl(0 100% 30%);
+    --cru-danger-container-1-color: hsl(0 100% 25%);
+    --cru-danger-container-2-color: hsl(0 100% 20%);
+    --cru-danger-on-container-color: hsl(0 100% 90%);
+    --cru-success-color: hsl(120 60% 80%);
+    --cru-success-1-color: hsl(120 60% 75%);
+    --cru-success-2-color: hsl(120 60% 68%);
+    --cru-success-on-color: hsl(120 60% 20%);
+    --cru-success-container-color: hsl(120 60% 30%);
+    --cru-success-container-1-color: hsl(120 60% 25%);
+    --cru-success-container-2-color: hsl(120 60% 20%);
+    --cru-success-on-container-color: hsl(120 60% 90%);
+    --cru-surface-dim-color: hsl(0 0% 6%);
+    --cru-surface-color: hsl(0 0% 6%);
+    --cru-surface-1-color: hsl(0 0% 25%);
+    --cru-surface-2-color: hsl(0 0% 40%);
+    --cru-surface-bright-color: hsl(0 0% 24%);
+    --cru-surface-container-lowest-color: hsl(0 0% 4%);
+    --cru-surface-container-low-color: hsl(0 0% 10%);
+    --cru-surface-container-color: hsl(0 0% 12%);
+    --cru-surface-container-high-color: hsl(0 0% 17%);
+    --cru-surface-container-highest-color: hsl(0 0% 22%);
+    --cru-surface-on-color: hsl(0 0% 90%);
+    --cru-surface-on-variant-color: hsl(0 0% 80%);
+    --cru-surface-outline-color: hsl(0 0% 60%);
+    --cru-surface-outline-variant-color: hsl(0 0% 30%);
+  }
+}
+
+.cru-primary {
+  --cru-key-color: var(--cru-primary-color);
+  --cru-key-1-color: var(--cru-primary-1-color);
+  --cru-key-2-color: var(--cru-primary-2-color);
+  --cru-key-on-color: var(--cru-primary-on-color);
+  --cru-key-container-color: var(--cru-primary-container-color);
+  --cru-key-container-1-color: var(--cru-primary-container-1-color);
+  --cru-key-container-2-color: var(--cru-primary-container-2-color);
+  --cru-key-on-container-color: var(--cru-primary-on-container-color);
+}
+
+.cru-secondary {
+  --cru-key-color: var(--cru-secondary-color);
+  --cru-key-1-color: var(--cru-secondary-1-color);
+  --cru-key-2-color: var(--cru-secondary-2-color);
+  --cru-key-on-color: var(--cru-secondary-on-color);
+  --cru-key-container-color: var(--cru-secondary-container-color);
+  --cru-key-container-1-color: var(--cru-secondary-container-1-color);
+  --cru-key-container-2-color: var(--cru-secondary-container-2-color);
+  --cru-key-on-container-color: var(--cru-secondary-on-container-color);
+}
+
+.cru-tertiary {
+  --cru-key-color: var(--cru-tertiary-color);
+  --cru-key-1-color: var(--cru-tertiary-1-color);
+  --cru-key-2-color: var(--cru-tertiary-2-color);
+  --cru-key-on-color: var(--cru-tertiary-on-color);
+  --cru-key-container-color: var(--cru-tertiary-container-color);
+  --cru-key-container-1-color: var(--cru-tertiary-container-1-color);
+  --cru-key-container-2-color: var(--cru-tertiary-container-2-color);
+  --cru-key-on-container-color: var(--cru-tertiary-on-container-color);
+}
+
+.cru-danger {
+  --cru-key-color: var(--cru-danger-color);
+  --cru-key-1-color: var(--cru-danger-1-color);
+  --cru-key-2-color: var(--cru-danger-2-color);
+  --cru-key-on-color: var(--cru-danger-on-color);
+  --cru-key-container-color: var(--cru-danger-container-color);
+  --cru-key-container-1-color: var(--cru-danger-container-1-color);
+  --cru-key-container-2-color: var(--cru-danger-container-2-color);
+  --cru-key-on-container-color: var(--cru-danger-on-container-color);
+}
+
+.cru-success {
+  --cru-key-color: var(--cru-success-color);
+  --cru-key-1-color: var(--cru-success-1-color);
+  --cru-key-2-color: var(--cru-success-2-color);
+  --cru-key-on-color: var(--cru-success-on-color);
+  --cru-key-container-color: var(--cru-success-container-color);
+  --cru-key-container-1-color: var(--cru-success-container-1-color);
+  --cru-key-container-2-color: var(--cru-success-container-2-color);
+  --cru-key-on-container-color: var(--cru-success-on-container-color);
+}
+
diff --git a/FrontEnd/src/components/theme.css b/FrontEnd/src/components/theme.css
new file mode 100644
index 00000000..6ceb369f
--- /dev/null
+++ b/FrontEnd/src/components/theme.css
@@ -0,0 +1,146 @@
+@import "./theme-color.css";
+
+:root {
+  --cru-default-font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
+  --cru-page-padding: 1em 2em;
+
+  --cru-border-radius: 4px;
+  --cru-card-border-radius: 4px;
+}
+
+/* theme colors */
+:root {
+  --cru-primary-color: hsl(210, 100%, 50%);
+  --cru-secondary-color: hsl(30, 100%, 50%);
+  --cru-create-color: hsl(120, 100%, 25%);
+  --cru-danger-color: hsl(0, 100%, 50%);
+}
+
+/* common colors */
+:root {
+  --cru-background-color: hsl(0, 0%, 100%);
+  --cru-container-background-color: hsl(0, 0%, 97%);
+  --cru-text-primary-color: hsl(0, 0%, 0%);
+  --cru-text-secondary-color: hsl(0, 0%, 38%);
+}
+
+@media (prefers-color-scheme: dark) {
+  :root {
+    --cru-background-color: hsl(0, 0%, 0%);
+    --cru-container-background-color: hsl(0, 0%, 2%);
+    --cru-text-primary-color: hsl(0, 0%, 100%);
+    --cru-text-secondary-color: hsl(0, 0%, 85%);
+  }
+}
+
+:root {
+  --cru-body-background-color: var(--cru-background-color);
+}
+
+/* clickable color */
+:root {
+  --cru-clickable-primary-normal-color: var(--cru-primary-color);
+  --cru-clickable-primary-hover-color: hsl(210, 100%, 60%);
+  --cru-clickable-primary-focus-color: hsl(210, 100%, 60%);
+  --cru-clickable-primary-active-color: hsl(210, 100%, 70%);
+  --cru-clickable-secondary-normal-color: var(--cru-secondary-color);
+  --cru-clickable-secondary-hover-color: hsl(30, 100%, 60%);
+  --cru-clickable-secondary-focus-color: hsl(30, 100%, 60%);
+  --cru-clickable-secondary-active-color: hsl(30, 100%, 70%);
+  --cru-clickable-create-normal-color: var(--cru-create-color);
+  --cru-clickable-create-hover-color: hsl(120, 100%, 35%);
+  --cru-clickable-create-focus-color: hsl(120, 100%, 35%);
+  --cru-clickable-create-active-color: hsl(120, 100%, 35%);
+  --cru-clickable-danger-normal-color: var(--cru-danger-color);
+  --cru-clickable-danger-hover-color: hsl(0, 100%, 60%);
+  --cru-clickable-danger-focus-color: hsl(0, 100%, 60%);
+  --cru-clickable-danger-active-color: hsl(0, 100%, 70%);
+  --cru-clickable-grayscale-normal-color: hsl(0, 0%, 100%);
+  --cru-clickable-grayscale-hover-color: hsl(0, 0%, 92%);
+  --cru-clickable-grayscale-focus-color: hsl(0, 0%, 92%);
+  --cru-clickable-grayscale-active-color: hsl(0, 0%, 88%);
+  --cru-clickable-disabled-color: hsl(0, 0%, 50%);
+}
+
+@media (prefers-color-scheme: dark) {
+  :root {
+    --cru-clickable-grayscale-normal-color: hsl(0, 0%, 0%);
+    --cru-clickable-grayscale-hover-color: hsl(0, 0%, 10%);
+    --cru-clickable-grayscale-focus-color: hsl(0, 0%, 10%);
+    --cru-clickable-grayscale-active-color: hsl(0, 0%, 20%);
+  }
+}
+
+.cru-clickable-primary {
+  --cru-clickable-normal-color: var(--cru-clickable-primary-normal-color);
+  --cru-clickable-hover-color: var(--cru-clickable-primary-hover-color);
+  --cru-clickable-focus-color: var(--cru-clickable-primary-focus-color);
+  --cru-clickable-active-color: var(--cru-clickable-primary-active-color);
+}
+
+.cru-clickable-secondary {
+  --cru-clickable-normal-color: var(--cru-clickable-secondary-normal-color);
+  --cru-clickable-hover-color: var(--cru-clickable-secondary-hover-color);
+  --cru-clickable-focus-color: var(--cru-clickable-secondary-focus-color);
+  --cru-clickable-active-color: var(--cru-clickable-secondary-active-color);
+}
+
+.cru-clickable-create {
+  --cru-clickable-normal-color: var(--cru-clickable-create-normal-color);
+  --cru-clickable-hover-color: var(--cru-clickable-create-hover-color);
+  --cru-clickable-focus-color: var(--cru-clickable-create-focus-color);
+  --cru-clickable-active-color: var(--cru-clickable-create-active-color);
+}
+
+.cru-clickable-danger {
+  --cru-clickable-normal-color: var(--cru-clickable-danger-normal-color);
+  --cru-clickable-hover-color: var(--cru-clickable-danger-hover-color);
+  --cru-clickable-focus-color: var(--cru-clickable-danger-focus-color);
+  --cru-clickable-active-color: var(--cru-clickable-danger-active-color);
+}
+
+.cru-clickable-grayscale {
+  --cru-clickable-normal-color: var(--cru-clickable-grayscale-normal-color);
+  --cru-clickable-hover-color: var(--cru-clickable-grayscale-hover-color);
+  --cru-clickable-focus-color: var(--cru-clickable-grayscale-focus-color);
+  --cru-clickable-active-color: var(--cru-clickable-grayscale-active-color);
+}
+
+/* button colors */
+:root {
+  /* push button colors */
+  --cru-push-button-text-color: #ffffff;
+  --cru-push-button-disabled-text-color: hsl(0, 0%, 80%);
+}
+
+/* Card colors */
+:root {
+  --cru-card-background-primary-color: hsl(210, 100%, 50%);
+  --cru-card-border-primary-color: hsl(210, 100%, 50%);
+  --cru-card-background-secondary-color: hsl(30, 100%, 50%);
+  --cru-card-border-secondary-color: hsl(30, 100%, 50%);
+  --cru-card-background-create-color: hsl(120, 100%, 25%);
+  --cru-card-border-create-color: hsl(120, 100%, 25%);
+  --cru-card-background-danger-color: hsl(0, 100%, 50%);
+  --cru-card-border-danger-color: hsl(0, 100%, 50%);
+}
+
+.cru-card-primary {
+  --cru-card-background-color: var(--cru-card-background-primary-color);
+  --cru-card-border-color: var(--cru-card-border-primary-color)
+}
+
+.cru-card-secondary {
+  --cru-card-background-color: var(--cru-card-background-secondary-color);
+  --cru-card-border-color: var(--cru-card-border-secondary-color)
+}
+
+.cru-card-create {
+  --cru-card-background-color: var(--cru-card-background-create-color);
+  --cru-card-border-color: var(--cru-card-border-create-color)
+}
+
+.cru-card-danger {
+  --cru-card-background-color: var(--cru-card-background-danger-color);
+  --cru-card-border-color: var(--cru-card-border-danger-color)
+}
\ No newline at end of file
diff --git a/FrontEnd/src/components/user/UserAvatar.tsx b/FrontEnd/src/components/user/UserAvatar.tsx
new file mode 100644
index 00000000..8671f2d8
--- /dev/null
+++ b/FrontEnd/src/components/user/UserAvatar.tsx
@@ -0,0 +1,22 @@
+import { Ref, ComponentPropsWithoutRef } from "react";
+
+import { getHttpUserClient } from "~src/http/user";
+
+export interface UserAvatarProps extends ComponentPropsWithoutRef<"img"> {
+  username: string;
+  imgRef?: Ref<HTMLImageElement> | null;
+}
+
+export default function UserAvatar({
+  username,
+  imgRef,
+  ...otherProps
+}: UserAvatarProps) {
+  return (
+    <img
+      ref={imgRef}
+      src={getHttpUserClient().generateAvatarUrl(username)}
+      {...otherProps}
+    />
+  );
+}
diff --git a/FrontEnd/src/http/bookmark.ts b/FrontEnd/src/http/bookmark.ts
index 40e121cc..311f9a0f 100644
--- a/FrontEnd/src/http/bookmark.ts
+++ b/FrontEnd/src/http/bookmark.ts
@@ -1,4 +1,4 @@
-import { withQuery } from "@/utilities/url";
+import { withQuery } from "~src/utilities/url";
 
 import { axios, apiBaseUrl, extractResponseData, Page } from "./common";
 
diff --git a/FrontEnd/src/http/timeline.ts b/FrontEnd/src/http/timeline.ts
index 401ae116..255c786e 100644
--- a/FrontEnd/src/http/timeline.ts
+++ b/FrontEnd/src/http/timeline.ts
@@ -1,4 +1,4 @@
-import { withQuery } from "@/utilities/url";
+import { withQuery } from "~src/utilities/url";
 
 import {
   axios,
diff --git a/FrontEnd/src/migrating/center/CenterBoards.tsx b/FrontEnd/src/migrating/center/CenterBoards.tsx
index a8be2c29..f1c3fc6a 100644
--- a/FrontEnd/src/migrating/center/CenterBoards.tsx
+++ b/FrontEnd/src/migrating/center/CenterBoards.tsx
@@ -1,13 +1,13 @@
 import * as React from "react";
 import { useTranslation } from "react-i18next";
 
-import { highlightTimelineUsername } from "@/common";
+import { highlightTimelineUsername } from "~src/common";
 
-import { pushAlert } from "@/services/alert";
-import { useUserLoggedIn } from "@/services/user";
+import { pushAlert } from "~src/services/alert";
+import { useUserLoggedIn } from "~src/services/user";
 
-import { getHttpTimelineClient } from "@/http/timeline";
-import { getHttpBookmarkClient } from "@/http/bookmark";
+import { getHttpTimelineClient } from "~src/http/timeline";
+import { getHttpBookmarkClient } from "~src/http/bookmark";
 
 import TimelineBoard from "./TimelineBoard";
 
diff --git a/FrontEnd/src/migrating/center/TimelineBoard.tsx b/FrontEnd/src/migrating/center/TimelineBoard.tsx
index b3ccdf8c..8f4401bc 100644
--- a/FrontEnd/src/migrating/center/TimelineBoard.tsx
+++ b/FrontEnd/src/migrating/center/TimelineBoard.tsx
@@ -2,7 +2,7 @@ import * as React from "react";
 import classnames from "classnames";
 import { Link } from "react-router-dom";
 
-import { TimelineBookmark } from "@/http/bookmark";
+import { TimelineBookmark } from "~src/http/bookmark";
 
 import TimelineLogo from "../common/TimelineLogo";
 import LoadFailReload from "../common/LoadFailReload";
diff --git a/FrontEnd/src/migrating/center/TimelineCreateDialog.tsx b/FrontEnd/src/migrating/center/TimelineCreateDialog.tsx
index 63742936..340a08fe 100644
--- a/FrontEnd/src/migrating/center/TimelineCreateDialog.tsx
+++ b/FrontEnd/src/migrating/center/TimelineCreateDialog.tsx
@@ -1,11 +1,11 @@
 import * as React from "react";
 import { useNavigate } from "react-router-dom";
 
-import { validateTimelineName } from "@/services/timeline";
-import { getHttpTimelineClient, HttpTimelineInfo } from "@/http/timeline";
+import { validateTimelineName } from "~src/services/timeline";
+import { getHttpTimelineClient, HttpTimelineInfo } from "~src/http/timeline";
 
 import OperationDialog from "../common/dialog/OperationDialog";
-import { useUserLoggedIn } from "@/services/user";
+import { useUserLoggedIn } from "~src/services/user";
 
 interface TimelineCreateDialogProps {
   open: boolean;
diff --git a/FrontEnd/src/migrating/center/index.tsx b/FrontEnd/src/migrating/center/index.tsx
index 77af2c20..11502517 100644
--- a/FrontEnd/src/migrating/center/index.tsx
+++ b/FrontEnd/src/migrating/center/index.tsx
@@ -1,7 +1,7 @@
 import * as React from "react";
 import { useNavigate } from "react-router-dom";
 
-import { useUserLoggedIn } from "@/services/user";
+import { useUserLoggedIn } from "~src/services/user";
 
 import SearchInput from "../common/SearchInput";
 import Button from "../common/button/Button";
diff --git a/FrontEnd/src/pages/about/index.tsx b/FrontEnd/src/pages/about/index.tsx
index acec1735..bce64322 100644
--- a/FrontEnd/src/pages/about/index.tsx
+++ b/FrontEnd/src/pages/about/index.tsx
@@ -1,7 +1,7 @@
 import "./index.css";
 
-import { useC } from "@/common";
-import Page from "@/views/common/Page";
+import { useC } from "~src/common";
+import Page from "~src/components/Page";
 
 interface Credit {
   name: string;
diff --git a/FrontEnd/src/pages/loading/index.tsx b/FrontEnd/src/pages/loading/index.tsx
index e4c8edab..29d27adc 100644
--- a/FrontEnd/src/pages/loading/index.tsx
+++ b/FrontEnd/src/pages/loading/index.tsx
@@ -1,4 +1,4 @@
-import Spinner from "@/views/common/Spinner";
+import Spinner from "~src/components/Spinner";
 
 import "./index.css";
 
diff --git a/FrontEnd/src/pages/login/index.tsx b/FrontEnd/src/pages/login/index.tsx
index a09e32c3..582ebd0f 100644
--- a/FrontEnd/src/pages/login/index.tsx
+++ b/FrontEnd/src/pages/login/index.tsx
@@ -2,16 +2,16 @@ import { useState, useEffect } from "react";
 import { Link, useNavigate } from "react-router-dom";
 import { Trans } from "react-i18next";
 
-import { useUser, userService } from "@/services/user";
+import { useUser, userService } from "~src/services/user";
 
-import { useC } from "@/views/common/common";
-import LoadingButton from "@/views/common/button/LoadingButton";
+import { useC } from "~src/components/common";
+import LoadingButton from "~src/components/button/LoadingButton";
 import {
   InputErrorDict,
   InputGroup,
   useInputs,
-} from "@/views/common/input/InputGroup";
-import Page from "@/views/common/Page";
+} from "~src/components/input/InputGroup";
+import Page from "~src/components/Page";
 
 import "./index.css";
 
diff --git a/FrontEnd/src/pages/register/index.tsx b/FrontEnd/src/pages/register/index.tsx
index bc474adb..9e478612 100644
--- a/FrontEnd/src/pages/register/index.tsx
+++ b/FrontEnd/src/pages/register/index.tsx
@@ -2,16 +2,16 @@ import { useState, useEffect } from "react";
 import { useTranslation } from "react-i18next";
 import { useNavigate } from "react-router-dom";
 
-import { HttpBadRequestError } from "@/http/common";
-import { getHttpTokenClient } from "@/http/token";
-import { userService, useUser } from "@/services/user";
+import { HttpBadRequestError } from "~src/http/common";
+import { getHttpTokenClient } from "~src/http/token";
+import { userService, useUser } from "~src/services/user";
 
-import { LoadingButton } from "@/views/common/button";
+import { LoadingButton } from "~src/components/button";
 import {
   useInputs,
   InputErrorDict,
   InputGroup,
-} from "@/views/common/input/InputGroup";
+} from "~src/components/input/InputGroup";
 
 import "./index.css";
 
diff --git a/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx b/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx
index f35fc5a7..c34bcf4f 100644
--- a/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx
+++ b/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx
@@ -1,19 +1,19 @@
 import { useState, ChangeEvent, ComponentPropsWithoutRef } from "react";
 
-import { useC, Text, UiLogicError } from "@/common";
+import { useC, Text, UiLogicError } from "~src/common";
 
-import { useUser } from "@/services/user";
+import { useUser } from "~src/services/user";
 
-import { getHttpUserClient } from "@/http/user";
+import { getHttpUserClient } from "~src/http/user";
 
 import ImageCropper, {
   Clip,
   applyClipToImage,
-} from "@/views/common/ImageCropper";
-import BlobImage from "@/views/common/BlobImage";
-import ButtonRowV2 from "@/views/common/button/ButtonRowV2";
-import Dialog from "@/views/common/dialog/Dialog";
-import DialogContainer from "@/views/common/dialog/DialogContainer";
+} from "~src/components/ImageCropper";
+import BlobImage from "~src/components/BlobImage";
+import ButtonRowV2 from "~src/components/button/ButtonRowV2";
+import Dialog from "~src/components/dialog/Dialog";
+import DialogContainer from "~src/components/dialog/DialogContainer";
 
 import "./ChangeAvatarDialog.css";
 
diff --git a/FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx b/FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx
index 4d318543..843659ef 100644
--- a/FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx
+++ b/FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx
@@ -1,7 +1,7 @@
-import { getHttpUserClient } from "@/http/user";
-import { useUserLoggedIn } from "@/services/user";
+import { getHttpUserClient } from "~src/http/user";
+import { useUserLoggedIn } from "~src/services/user";
 
-import OperationDialog from "@/views/common/dialog/OperationDialog";
+import OperationDialog from "~src/components/dialog/OperationDialog";
 
 export interface ChangeNicknameDialogProps {
   open: boolean;
@@ -26,7 +26,7 @@ export default function ChangeNicknameDialog(props: ChangeNicknameDialogProps) {
       ]}
       onProcess={({ newNickname }) => {
         return getHttpUserClient().patch(user.username, {
-          nickname: newNickname as string,
+          nickname: newNickname,
         });
       }}
       onClose={onClose}
diff --git a/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx b/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx
index 87a970a5..bfcea92d 100644
--- a/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx
+++ b/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx
@@ -1,11 +1,11 @@
 import { useState } from "react";
 import { useNavigate } from "react-router-dom";
 
-import { userService } from "@/services/user";
+import { userService } from "~src/services/user";
 
 import OperationDialog, {
   InputErrorDict,
-} from "@/views/common/dialog/OperationDialog";
+} from "~src/components/dialog/OperationDialog";
 
 interface ChangePasswordDialogProps {
   open: boolean;
@@ -65,10 +65,7 @@ export function ChangePasswordDialog(props: ChangePasswordDialogProps) {
         },
       }}
       onProcess={async ({ oldPassword, newPassword }) => {
-        await userService.changePassword(
-          oldPassword as string,
-          newPassword as string,
-        );
+        await userService.changePassword(oldPassword, newPassword);
         setRedirect(true);
       }}
       onSuccessAndClose={() => {
diff --git a/FrontEnd/src/pages/setting/index.tsx b/FrontEnd/src/pages/setting/index.tsx
index 50967a3c..67416a08 100644
--- a/FrontEnd/src/pages/setting/index.tsx
+++ b/FrontEnd/src/pages/setting/index.tsx
@@ -8,21 +8,21 @@ import { useTranslation } from "react-i18next";
 import { useNavigate } from "react-router-dom";
 import classNames from "classnames";
 
-import { useC, Text } from "@/common";
-import { useUser, userService } from "@/services/user";
-import { getHttpUserClient } from "@/http/user";
+import { useC, Text } from "~src/common";
+import { useUser, userService } from "~src/services/user";
+import { getHttpUserClient } from "~src/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";
-import Page from "@/views/common/Page";
+import { useDialog } from "~src/components/dialog";
+import ConfirmDialog from "~src/components/dialog/ConfirmDialog";
+import Card from "~src/components/Card";
+import Spinner from "~src/components/Spinner";
+import Page from "~src/components/Page";
 import ChangePasswordDialog from "./ChangePasswordDialog";
 import ChangeAvatarDialog from "./ChangeAvatarDialog";
 import ChangeNicknameDialog from "./ChangeNicknameDialog";
 
 import "./index.css";
-import { pushAlert } from "@/services/alert";
+import { pushAlert } from "~src/services/alert";
 
 interface SettingSectionProps
   extends Omit<ComponentPropsWithoutRef<typeof Card>, "title"> {
diff --git a/FrontEnd/src/pages/timeline/CollapseButton.tsx b/FrontEnd/src/pages/timeline/CollapseButton.tsx
index 14fc6bee..1c4fa2ba 100644
--- a/FrontEnd/src/pages/timeline/CollapseButton.tsx
+++ b/FrontEnd/src/pages/timeline/CollapseButton.tsx
@@ -1,6 +1,6 @@
 import { CSSProperties } from "react";
 
-import IconButton from "@/views/common/button/IconButton";
+import IconButton from "~src/components/button/IconButton";
 
 export default function CollapseButton({
   collapse,
diff --git a/FrontEnd/src/pages/timeline/MarkdownPostEdit.tsx b/FrontEnd/src/pages/timeline/MarkdownPostEdit.tsx
index 9c497108..43e81d67 100644
--- a/FrontEnd/src/pages/timeline/MarkdownPostEdit.tsx
+++ b/FrontEnd/src/pages/timeline/MarkdownPostEdit.tsx
@@ -2,15 +2,15 @@ import * as React from "react";
 import classnames from "classnames";
 import { useTranslation } from "react-i18next";
 
-import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline";
+import { getHttpTimelineClient, HttpTimelinePostInfo } from "~src/http/timeline";
 
-import TimelinePostBuilder from "@/services/TimelinePostBuilder";
+import TimelinePostBuilder from "~src/services/TimelinePostBuilder";
 
-import FlatButton from "@/views/common/button/FlatButton";
-import TabPages from "@/views/common/tab/TabPages";
-import ConfirmDialog from "@/views/common/dialog/ConfirmDialog";
-import Spinner from "@/views/common/Spinner";
-import IconButton from "@/views/common/button/IconButton";
+import FlatButton from "~src/components/button/FlatButton";
+import TabPages from "~src/components/tab/TabPages";
+import ConfirmDialog from "~src/components/dialog/ConfirmDialog";
+import Spinner from "~src/components/Spinner";
+import IconButton from "~src/components/button/IconButton";
 
 import "./MarkdownPostEdit.css";
 
diff --git a/FrontEnd/src/pages/timeline/Timeline.tsx b/FrontEnd/src/pages/timeline/Timeline.tsx
index 73e621c1..f266ec9d 100644
--- a/FrontEnd/src/pages/timeline/Timeline.tsx
+++ b/FrontEnd/src/pages/timeline/Timeline.tsx
@@ -1,20 +1,20 @@
 import { useState, useEffect } from "react";
 import classnames from "classnames";
-import { useScrollToBottom } from "@/utilities/hooks";
+import { useScrollToBottom } from "~src/utilities/hooks";
 import { HubConnectionState } from "@microsoft/signalr";
 
 import {
   HttpForbiddenError,
   HttpNetworkError,
   HttpNotFoundError,
-} from "@/http/common";
+} from "~src/http/common";
 import {
   getHttpTimelineClient,
   HttpTimelineInfo,
   HttpTimelinePostInfo,
-} from "@/http/timeline";
+} from "~src/http/timeline";
 
-import { getTimelinePostUpdate$ } from "@/services/timeline";
+import { getTimelinePostUpdate$ } from "~src/services/timeline";
 
 import TimelinePostList from "./TimelinePostList";
 import TimelinePostEdit from "./TimelinePostCreateView";
diff --git a/FrontEnd/src/pages/timeline/TimelineCard.tsx b/FrontEnd/src/pages/timeline/TimelineCard.tsx
index 2987aa74..82d6d350 100644
--- a/FrontEnd/src/pages/timeline/TimelineCard.tsx
+++ b/FrontEnd/src/pages/timeline/TimelineCard.tsx
@@ -1,24 +1,24 @@
 import { useState } from "react";
 import { HubConnectionState } from "@microsoft/signalr";
 
-import { useUser } from "@/services/user";
-import { pushAlert } from "@/services/alert";
+import { useUser } from "~src/services/user";
+import { pushAlert } from "~src/services/alert";
 
-import { HttpTimelineInfo } from "@/http/timeline";
-import { getHttpBookmarkClient } from "@/http/bookmark";
+import { HttpTimelineInfo } from "~src/http/timeline";
+import { getHttpBookmarkClient } from "~src/http/bookmark";
 
-import { useMobile } from "@/views/common/common";
-import { Dialog, 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";
-import Card from "@/views/common/Card";
+import { useMobile } from "~src/components/common";
+import { Dialog, useDialog } from "~src/components/dialog";
+import UserAvatar from "~src/components/user/UserAvatar";
+import PopupMenu from "~src/components/menu/PopupMenu";
+import FullPageDialog from "~src/components/dialog/FullPageDialog";
+import Card from "~src/components/Card";
 import TimelineDeleteDialog from "./TimelineDeleteDialog";
 import ConnectionStatusBadge from "./ConnectionStatusBadge";
 import CollapseButton from "./CollapseButton";
 import TimelineMember from "./TimelineMember";
 import TimelinePropertyChangeDialog from "./TimelinePropertyChangeDialog";
-import IconButton from "@/views/common/button/IconButton";
+import IconButton from "~src/components/button/IconButton";
 
 import "./TimelineCard.css";
 
diff --git a/FrontEnd/src/pages/timeline/TimelineDeleteDialog.tsx b/FrontEnd/src/pages/timeline/TimelineDeleteDialog.tsx
index 7d7b9527..7b7b8e8c 100644
--- a/FrontEnd/src/pages/timeline/TimelineDeleteDialog.tsx
+++ b/FrontEnd/src/pages/timeline/TimelineDeleteDialog.tsx
@@ -2,9 +2,9 @@ import * as React from "react";
 import { useNavigate } from "react-router-dom";
 import { Trans } from "react-i18next";
 
-import { getHttpTimelineClient, HttpTimelineInfo } from "@/http/timeline";
+import { getHttpTimelineClient, HttpTimelineInfo } from "~src/http/timeline";
 
-import OperationDialog from "@/views/common/dialog/OperationDialog";
+import OperationDialog from "~src/components/dialog/OperationDialog";
 
 interface TimelineDeleteDialog {
   timeline: HttpTimelineInfo;
diff --git a/FrontEnd/src/pages/timeline/TimelineMember.tsx b/FrontEnd/src/pages/timeline/TimelineMember.tsx
index 4c1600f5..a25fe6a9 100644
--- a/FrontEnd/src/pages/timeline/TimelineMember.tsx
+++ b/FrontEnd/src/pages/timeline/TimelineMember.tsx
@@ -1,16 +1,16 @@
 import { useState } from "react";
 import { useTranslation } from "react-i18next";
 
-import { convertI18nText, I18nText } from "@/common";
+import { convertI18nText, I18nText } from "~src/common";
 
-import { HttpUser } from "@/http/user";
-import { getHttpSearchClient } from "@/http/search";
-import { getHttpTimelineClient, HttpTimelineInfo } from "@/http/timeline";
+import { HttpUser } from "~src/http/user";
+import { getHttpSearchClient } from "~src/http/search";
+import { getHttpTimelineClient, HttpTimelineInfo } from "~src/http/timeline";
 
-import SearchInput from "@/views/common/SearchInput";
-import UserAvatar from "@/views/common/user/UserAvatar";
-import Button from "@/views/common/button/Button";
-import { ListContainer, ListItemContainer } from "@/views/common/list";
+import SearchInput from "~src/components/SearchInput";
+import UserAvatar from "~src/components/user/UserAvatar";
+import Button from "~src/components/button/Button";
+import { ListContainer, ListItemContainer } from "~src/components/list";
 
 import "./TimelineMember.css";
 
diff --git a/FrontEnd/src/pages/timeline/TimelinePostCard.tsx b/FrontEnd/src/pages/timeline/TimelinePostCard.tsx
index 23dd141f..d3fd3215 100644
--- a/FrontEnd/src/pages/timeline/TimelinePostCard.tsx
+++ b/FrontEnd/src/pages/timeline/TimelinePostCard.tsx
@@ -1,7 +1,7 @@
 import { ReactNode } from "react";
 import classNames from "classnames";
 
-import Card from "@/views/common/Card";
+import Card from "~src/components/Card";
 
 import "./TimelinePostCard.css";
 
diff --git a/FrontEnd/src/pages/timeline/TimelinePostContentView.tsx b/FrontEnd/src/pages/timeline/TimelinePostContentView.tsx
index ad5465c1..6c0d7387 100644
--- a/FrontEnd/src/pages/timeline/TimelinePostContentView.tsx
+++ b/FrontEnd/src/pages/timeline/TimelinePostContentView.tsx
@@ -2,15 +2,15 @@ import * as React from "react";
 import classnames from "classnames";
 import { marked } from "marked";
 
-import { UiLogicError } from "@/common";
+import { UiLogicError } from "~src/common";
 
-import { HttpNetworkError } from "@/http/common";
-import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline";
+import { HttpNetworkError } from "~src/http/common";
+import { getHttpTimelineClient, HttpTimelinePostInfo } from "~src/http/timeline";
 
-import { useUser } from "@/services/user";
+import { useUser } from "~src/services/user";
 
-import Skeleton from "@/views/common/Skeleton";
-import LoadFailReload from "@/views/common/LoadFailReload";
+import Skeleton from "~src/components/Skeleton";
+import LoadFailReload from "~src/components/LoadFailReload";
 
 const TextView: React.FC<TimelinePostContentViewProps> = (props) => {
   const { post, className, style } = props;
diff --git a/FrontEnd/src/pages/timeline/TimelinePostCreateView.tsx b/FrontEnd/src/pages/timeline/TimelinePostCreateView.tsx
index 572a9119..3c41228a 100644
--- a/FrontEnd/src/pages/timeline/TimelinePostCreateView.tsx
+++ b/FrontEnd/src/pages/timeline/TimelinePostCreateView.tsx
@@ -1,26 +1,26 @@
 import { useState, useEffect, ChangeEventHandler } from "react";
 import { useTranslation } from "react-i18next";
 
-import { UiLogicError } from "@/common";
+import { UiLogicError } from "~src/common";
 
 import {
   getHttpTimelineClient,
   HttpTimelineInfo,
   HttpTimelinePostInfo,
   HttpTimelinePostPostRequestData,
-} from "@/http/timeline";
+} from "~src/http/timeline";
 
-import { pushAlert } from "@/services/alert";
+import { pushAlert } from "~src/services/alert";
 
-import base64 from "@/utilities/base64";
+import base64 from "~src/utilities/base64";
 
-import BlobImage from "@/views/common/BlobImage";
-import LoadingButton from "@/views/common/button/LoadingButton";
-import PopupMenu from "@/views/common/menu/PopupMenu";
+import BlobImage from "~src/components/BlobImage";
+import LoadingButton from "~src/components/button/LoadingButton";
+import PopupMenu from "~src/components/menu/PopupMenu";
 import MarkdownPostEdit from "./MarkdownPostEdit";
 import TimelinePostCard from "./TimelinePostCard";
 import TimelinePostContainer from "./TimelinePostContainer";
-import IconButton from "@/views/common/button/IconButton";
+import IconButton from "~src/components/button/IconButton";
 
 import "./TimelinePostCreateView.css";
 import classNames from "classnames";
diff --git a/FrontEnd/src/pages/timeline/TimelinePostList.tsx b/FrontEnd/src/pages/timeline/TimelinePostList.tsx
index a3501b33..7912260a 100644
--- a/FrontEnd/src/pages/timeline/TimelinePostList.tsx
+++ b/FrontEnd/src/pages/timeline/TimelinePostList.tsx
@@ -1,6 +1,6 @@
 import { useMemo, Fragment } from "react";
 
-import { HttpTimelinePostInfo } from "@/http/timeline";
+import { HttpTimelinePostInfo } from "~src/http/timeline";
 
 import TimelinePostView from "./TimelinePostView";
 import TimelineDateLabel from "./TimelineDateLabel";
diff --git a/FrontEnd/src/pages/timeline/TimelinePostView.tsx b/FrontEnd/src/pages/timeline/TimelinePostView.tsx
index afae5033..2a8c5947 100644
--- a/FrontEnd/src/pages/timeline/TimelinePostView.tsx
+++ b/FrontEnd/src/pages/timeline/TimelinePostView.tsx
@@ -1,17 +1,17 @@
 import { useState } from "react";
 
-import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline";
+import { getHttpTimelineClient, HttpTimelinePostInfo } from "~src/http/timeline";
 
-import { pushAlert } from "@/services/alert";
+import { pushAlert } from "~src/services/alert";
 
-import { useClickOutside } from "@/utilities/hooks";
+import { useClickOutside } from "~src/utilities/hooks";
 
-import UserAvatar from "@/views/common/user/UserAvatar";
-import { useDialog } from "@/views/common/dialog";
-import FlatButton from "@/views/common/button/FlatButton";
-import ConfirmDialog from "@/views/common/dialog/ConfirmDialog";
+import UserAvatar from "~src/components/user/UserAvatar";
+import { useDialog } from "~src/components/dialog";
+import FlatButton from "~src/components/button/FlatButton";
+import ConfirmDialog from "~src/components/dialog/ConfirmDialog";
 import TimelinePostContentView from "./TimelinePostContentView";
-import IconButton from "@/views/common/button/IconButton";
+import IconButton from "~src/components/button/IconButton";
 
 import TimelinePostContainer from "./TimelinePostContainer";
 import TimelinePostCard from "./TimelinePostCard";
diff --git a/FrontEnd/src/pages/timeline/TimelinePropertyChangeDialog.tsx b/FrontEnd/src/pages/timeline/TimelinePropertyChangeDialog.tsx
index b57135bb..afd83a5f 100644
--- a/FrontEnd/src/pages/timeline/TimelinePropertyChangeDialog.tsx
+++ b/FrontEnd/src/pages/timeline/TimelinePropertyChangeDialog.tsx
@@ -6,9 +6,9 @@ import {
   HttpTimelinePatchRequest,
   kTimelineVisibilities,
   TimelineVisibility,
-} from "@/http/timeline";
+} from "~src/http/timeline";
 
-import OperationDialog from "@/views/common/dialog/OperationDialog";
+import OperationDialog from "~src/components/dialog/OperationDialog";
 
 export interface TimelinePropertyChangeDialogProps {
   open: boolean;
@@ -68,13 +68,13 @@ const TimelinePropertyChangeDialog: React.FC<
       onProcess={({ title, visibility, description }) => {
         const req: HttpTimelinePatchRequest = {};
         if (title !== timeline.title) {
-          req.title = title as string;
+          req.title = title;
         }
         if (visibility !== timeline.visibility) {
-          req.visibility = visibility as TimelineVisibility;
+          req.visibility = visibility;
         }
         if (description !== timeline.description) {
-          req.description = description as string;
+          req.description = description;
         }
         return getHttpTimelineClient()
           .patchTimeline(timeline.owner.username, timeline.nameV2, req)
diff --git a/FrontEnd/src/pages/timeline/index.tsx b/FrontEnd/src/pages/timeline/index.tsx
index 51cc37f0..6cd1ded0 100644
--- a/FrontEnd/src/pages/timeline/index.tsx
+++ b/FrontEnd/src/pages/timeline/index.tsx
@@ -1,6 +1,6 @@
 import { useParams } from "react-router-dom";
 
-import { UiLogicError } from "@/common";
+import { UiLogicError } from "~src/common";
 
 import Timeline from "./Timeline";
 
diff --git a/FrontEnd/src/services/TimelinePostBuilder.ts b/FrontEnd/src/services/TimelinePostBuilder.ts
index 0440e136..919d4f55 100644
--- a/FrontEnd/src/services/TimelinePostBuilder.ts
+++ b/FrontEnd/src/services/TimelinePostBuilder.ts
@@ -1,10 +1,10 @@
 import { marked } from "marked";
 
-import { UiLogicError } from "@/common";
+import { UiLogicError } from "~src/common";
 
-import base64 from "@/utilities/base64";
+import base64 from "~src/utilities/base64";
 
-import { HttpTimelinePostPostRequest } from "@/http/timeline";
+import { HttpTimelinePostPostRequest } from "~src/http/timeline";
 
 class TimelinePostMarkedRenderer extends marked.Renderer {
   constructor(private _images: { file: File; url: string }[]) {
diff --git a/FrontEnd/src/services/alert.ts b/FrontEnd/src/services/alert.ts
index 2f66dccc..241deab1 100644
--- a/FrontEnd/src/services/alert.ts
+++ b/FrontEnd/src/services/alert.ts
@@ -1,7 +1,7 @@
 import pull from "lodash/pull";
 
-import { I18nText } from "@/common";
-import { ThemeColor } from "@/views/common/common";
+import { I18nText } from "~src/common";
+import { ThemeColor } from "~src/views/common/common";
 
 export interface AlertInfo {
   type?: ThemeColor;
diff --git a/FrontEnd/src/services/timeline.ts b/FrontEnd/src/services/timeline.ts
index 342f803a..41a7bff0 100644
--- a/FrontEnd/src/services/timeline.ts
+++ b/FrontEnd/src/services/timeline.ts
@@ -13,8 +13,8 @@ import {
   HubConnectionState,
 } from "@microsoft/signalr";
 
-import { TimelineVisibility } from "@/http/timeline";
-import { token$ } from "@/http/common";
+import { TimelineVisibility } from "~src/http/timeline";
+import { token$ } from "~src/http/common";
 
 // cSpell:ignore onreconnected onreconnecting
 
diff --git a/FrontEnd/src/services/user.ts b/FrontEnd/src/services/user.ts
index c89ca893..ddba4dab 100644
--- a/FrontEnd/src/services/user.ts
+++ b/FrontEnd/src/services/user.ts
@@ -2,11 +2,11 @@ import { useState, useEffect } from "react";
 import { BehaviorSubject, Observable } from "rxjs";
 import { AxiosError } from "axios";
 
-import { UiLogicError } from "@/common";
+import { UiLogicError } from "~src/common";
 
-import { setHttpToken, axios, HttpBadRequestError } from "@/http/common";
-import { getHttpTokenClient } from "@/http/token";
-import { getHttpUserClient, HttpUser, UserPermission } from "@/http/user";
+import { setHttpToken, axios, HttpBadRequestError } from "~src/http/common";
+import { getHttpTokenClient } from "~src/http/token";
+import { getHttpUserClient, HttpUser, UserPermission } from "~src/http/user";
 
 import { pushAlert } from "./alert";
 
diff --git a/FrontEnd/src/views/common/AppBar.css b/FrontEnd/src/views/common/AppBar.css
deleted file mode 100644
index a0d975b5..00000000
--- a/FrontEnd/src/views/common/AppBar.css
+++ /dev/null
@@ -1,87 +0,0 @@
-.app-bar {
-  height: 56px;
-  position: fixed;
-  z-index: 1030;
-  top: 0;
-  left: 0;
-  right: 0;
-  background-color: var(--cru-primary-color);
-}
-
-.app-bar {
-  display: flex;
-}
-
-.app-bar .app-bar-brand {
-  display: flex;
-  align-items: center;
-}
-
-.app-bar .app-bar-brand-icon {
-  height: 2em;
-}
-
-.app-bar .app-bar-user-area {
-  display: flex;
-  margin-left: auto;
-}
-
-.app-bar a {
-  background-color: var(--cru-primary-color);
-  color: var(--cru-push-button-text-color);
-  text-decoration: none;
-  display: flex;
-  align-items: center;
-  padding: 0 1em;
-  transition: all 0.5s;
-}
-
-.app-bar a:hover {
-  background-color: var(--cru-clickable-primary-hover-color);
-}
-
-.app-bar a:focus {
-  background-color: var(--cru-clickable-primary-focus-color);
-}
-
-.app-bar a:active {
-  background-color: var(--cru-clickable-primary-active-color);
-}
-
-/* the current page */
-.app-bar a.active {
-  background-color: var(--cru-clickable-primary-focus-color);
-}
-
-.app-bar .app-bar-avatar img {
-  width: 45px;
-  height: 45px;
-  background-color: white;
-  border-radius: 50%;
-}
-
-.app-bar.desktop .app-bar-link-area {
-  display: flex;
-}
-
-.app-bar.mobile .app-bar-link-area {
-  position: absolute;
-  z-index: -1;
-  top: 56px;
-  left: 0;
-  right: 0;
-  transition: transform 0.5s;
-}
-
-.app-bar.mobile a {
-  height: 56px;
-}
-
-.app-bar.mobile.collapse .app-bar-link-area {
-  transform: translateY(-100%);
-}
-
-.app-bar .toggler {
-  font-size: 2em;
-  margin-right: 0.5em;
-}
\ No newline at end of file
diff --git a/FrontEnd/src/views/common/AppBar.tsx b/FrontEnd/src/views/common/AppBar.tsx
deleted file mode 100644
index b9ea825b..00000000
--- a/FrontEnd/src/views/common/AppBar.tsx
+++ /dev/null
@@ -1,98 +0,0 @@
-import { useState } from "react";
-import classnames from "classnames";
-import { Link, NavLink } from "react-router-dom";
-
-import { I18nText, useC, useMobile } from "./common";
-import { useUser } from "@/services/user";
-
-import TimelineLogo from "./TimelineLogo";
-import { IconButton } from "./button";
-import UserAvatar from "./user/UserAvatar";
-
-import "./AppBar.css";
-
-function AppBarNavLink({
-  link,
-  className,
-  label,
-  onClick,
-  children,
-}: {
-  link: string;
-  className?: string;
-  label?: I18nText;
-  onClick?: () => void;
-  children?: React.ReactNode;
-}) {
-  if (label != null && children != null) {
-    throw new Error("AppBarNavLink: label and children cannot be both set");
-  }
-
-  const c = useC();
-
-  return (
-    <NavLink
-      to={link}
-      className={({ isActive }) => classnames(className, isActive && "active")}
-      onClick={onClick}
-    >
-      {children != null ? children : c(label)}
-    </NavLink>
-  );
-}
-
-export default function AppBar() {
-  const isMobile = useMobile();
-
-  const [isCollapse, setIsCollapse] = useState<boolean>(true);
-  const collapse = isMobile ? () => setIsCollapse(true) : undefined;
-  const toggleCollapse = () => setIsCollapse(!isCollapse);
-
-  const user = useUser();
-  const hasAdministrationPermission = user && user.hasAdministrationPermission;
-
-  return (
-    <nav
-      className={classnames(
-        isMobile ? "mobile" : "desktop",
-        "app-bar",
-        isCollapse && "collapse",
-      )}
-    >
-      <Link to="/" className="app-bar-brand" onClick={collapse}>
-        <TimelineLogo className="app-bar-brand-icon" />
-        Timeline
-      </Link>
-
-      <div className="app-bar-link-area">
-        <AppBarNavLink
-          link="/settings"
-          label="nav.settings"
-          onClick={collapse}
-        />
-        <AppBarNavLink link="/about" label="nav.about" onClick={collapse} />
-        {hasAdministrationPermission && (
-          <AppBarNavLink
-            link="/admin"
-            label="nav.administration"
-            onClick={collapse}
-          />
-        )}
-      </div>
-
-      <div className="app-bar-user-area">
-        {user != null ? (
-          <AppBarNavLink link="/" className="app-bar-avatar" onClick={collapse}>
-            <UserAvatar username={user.username} />
-          </AppBarNavLink>
-        ) : (
-          <AppBarNavLink link="/login" label="nav.login" onClick={collapse} />
-        )}
-      </div>
-
-      {isMobile && (
-        <IconButton icon="list" className="toggler" onClick={toggleCollapse} />
-      )}
-    </nav>
-  );
-}
diff --git a/FrontEnd/src/views/common/BlobImage.tsx b/FrontEnd/src/views/common/BlobImage.tsx
deleted file mode 100644
index 259c2210..00000000
--- a/FrontEnd/src/views/common/BlobImage.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-import { ComponentPropsWithoutRef, useState, useEffect } from "react";
-
-type BlobImageProps = Omit<ComponentPropsWithoutRef<"img">, "src"> & {
-  imgRef?: React.Ref<HTMLImageElement>;
-  src?: Blob | string | null;
-};
-
-export default function BlobImage(props: BlobImageProps) {
-  const { imgRef, src, ...otherProps } = props;
-
-  const [url, setUrl] = useState<string | null | undefined>(undefined);
-
-  useEffect(() => {
-    if (src instanceof Blob) {
-      const url = URL.createObjectURL(src);
-      setUrl(url);
-      return () => {
-        URL.revokeObjectURL(url);
-      };
-    } else {
-      setUrl(src);
-    }
-  }, [src]);
-
-  return <img ref={imgRef} {...otherProps} src={url ?? undefined} />;
-}
diff --git a/FrontEnd/src/views/common/Card.css b/FrontEnd/src/views/common/Card.css
deleted file mode 100644
index 6d655eb9..00000000
--- a/FrontEnd/src/views/common/Card.css
+++ /dev/null
@@ -1,20 +0,0 @@
-.cru-card {
-  border-radius: var(--cru-card-border-radius);
-  transition: all 0.3s;
-}
-
-.cru-card-background-none {
-  background-color: transparent;
-}
-
-.cru-card-background-solid {
-  background-color: var(--cru-background-color);
-}
-
-.cru-card-background-grayscale {
-  background-color: var(--cru-container-background-color);
-}
-
-.cru-card-border-color {
-  border: 2px solid var(--cru-card-border-color);
-}
diff --git a/FrontEnd/src/views/common/Card.tsx b/FrontEnd/src/views/common/Card.tsx
deleted file mode 100644
index a8f0d3cc..00000000
--- a/FrontEnd/src/views/common/Card.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import { ComponentPropsWithoutRef, Ref } from "react";
-import classNames from "classnames";
-
-import { ThemeColor } from "./common";
-import "./Card.css";
-
-interface CardProps extends ComponentPropsWithoutRef<"div"> {
-  containerRef?: Ref<HTMLDivElement>;
-  color?: ThemeColor;
-  border?: "color" | "none";
-  background?: "color" | "solid" | "grayscale" | "none";
-}
-
-export default function Card({
-  color,
-  background,
-  border,
-  className,
-  children,
-  containerRef,
-  ...otherProps
-}: CardProps) {
-  return (
-    <div
-      ref={containerRef}
-      className={classNames(
-        "cru-card",
-        `cru-card-${color ?? "primary"}`,
-        `cru-card-border-${border ?? "color"}`,
-        `cru-card-background-${background ?? "solid"}`,
-        className,
-      )}
-      {...otherProps}
-    >
-      {children}
-    </div>
-  );
-}
diff --git a/FrontEnd/src/views/common/Icon.css b/FrontEnd/src/views/common/Icon.css
deleted file mode 100644
index fe980d7b..00000000
--- a/FrontEnd/src/views/common/Icon.css
+++ /dev/null
@@ -1,3 +0,0 @@
-.cru-icon {
-  font-size: 1.4rem;
-}
diff --git a/FrontEnd/src/views/common/Icon.tsx b/FrontEnd/src/views/common/Icon.tsx
deleted file mode 100644
index 2ac3a7ca..00000000
--- a/FrontEnd/src/views/common/Icon.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import { ComponentPropsWithoutRef } from "react";
-import classNames from "classnames";
-
-import { ThemeColor } from "./common";
-
-import "./Icon.css";
-
-interface IconButtonProps extends ComponentPropsWithoutRef<"i"> {
-  icon: string;
-  color?: ThemeColor | "on-surface";
-  size?: string | number;
-}
-
-export default function Icon(props: IconButtonProps) {
-  const { icon, color, size, style, className, ...otherProps } = props;
-
-  const colorName = color === "on-surface" ? "surface-on" : color;
-
-  return (
-    <i
-      style={size != null ? { ...style, fontSize: size } : style}
-      className={classNames(
-        colorName && `cru-${colorName}`,
-        `bi-${icon} cru-icon`,
-        className,
-      )}
-      {...otherProps}
-    />
-  );
-}
diff --git a/FrontEnd/src/views/common/ImageCropper.css b/FrontEnd/src/views/common/ImageCropper.css
deleted file mode 100644
index 2c4d0a8c..00000000
--- a/FrontEnd/src/views/common/ImageCropper.css
+++ /dev/null
@@ -1,38 +0,0 @@
-.image-cropper-container {
-  position: relative;
-  box-sizing: border-box;
-  user-select: none;
-}
-
-.image-cropper-container img {
-  position: absolute;
-  left: 0;
-  top: 0;
-  width: 100%;
-  height: 100%;
-}
-
-.image-cropper-mask-container {
-  position: absolute;
-  left: 0;
-  top: 0;
-  right: 0;
-  bottom: 0;
-  overflow: hidden;
-}
-
-.image-cropper-mask {
-  position: absolute;
-  box-shadow: 0 0 0 10000px rgba(255, 255, 255, 0.8);
-  touch-action: none;
-}
-
-.image-cropper-handler {
-  position: absolute;
-  width: 26px;
-  height: 26px;
-  border: black solid 2px;
-  border-radius: 50%;
-  background: white;
-  touch-action: none;
-}
diff --git a/FrontEnd/src/views/common/ImageCropper.tsx b/FrontEnd/src/views/common/ImageCropper.tsx
deleted file mode 100644
index fcab74b0..00000000
--- a/FrontEnd/src/views/common/ImageCropper.tsx
+++ /dev/null
@@ -1,312 +0,0 @@
-import * as React from "react";
-import classnames from "classnames";
-
-import { UiLogicError } from "@/common";
-
-import "./ImageCropper.css";
-import BlobImage from "./BlobImage";
-
-export interface Clip {
-  left: number;
-  top: number;
-  width: number;
-}
-
-interface NormailizedClip extends Clip {
-  height: number;
-}
-
-interface ImageInfo {
-  width: number;
-  height: number;
-  landscape: boolean;
-  ratio: number;
-  maxClipWidth: number;
-  maxClipHeight: number;
-}
-
-interface ImageCropperSavedState {
-  clip: NormailizedClip;
-  x: number;
-  y: number;
-  pointerId: number;
-}
-
-export interface ImageCropperProps {
-  clip: Clip | null;
-  image: string | Blob;
-  onChange: (clip: Clip) => void;
-  imageElementCallback?: (element: HTMLImageElement | null) => void;
-  className?: string;
-}
-
-const ImageCropper = (props: ImageCropperProps): React.ReactElement => {
-  const { clip, image, onChange, imageElementCallback, className } = props;
-
-  const [oldState, setOldState] = React.useState<ImageCropperSavedState | null>(
-    null,
-  );
-  const [imageInfo, setImageInfo] = React.useState<ImageInfo | null>(null);
-
-  const normalizeClip = (c: Clip | null | undefined): NormailizedClip => {
-    if (c == null) {
-      return { left: 0, top: 0, width: 0, height: 0 };
-    }
-
-    return {
-      left: c.left || 0,
-      top: c.top || 0,
-      width: c.width || 0,
-      height: imageInfo != null ? (c.width || 0) / imageInfo.ratio : 0,
-    };
-  };
-
-  const c = normalizeClip(clip);
-
-  const imgElementRef = React.useRef<HTMLImageElement | null>(null);
-
-  const onImageRef = React.useCallback(
-    (e: HTMLImageElement | null) => {
-      imgElementRef.current = e;
-      if (imageElementCallback != null && e == null) {
-        imageElementCallback(null);
-      }
-    },
-    [imageElementCallback],
-  );
-
-  const onImageLoad = React.useCallback(
-    (e: React.SyntheticEvent<HTMLImageElement>) => {
-      const img = e.currentTarget;
-      const landscape = img.naturalWidth >= img.naturalHeight;
-
-      const info = {
-        width: img.naturalWidth,
-        height: img.naturalHeight,
-        landscape,
-        ratio: img.naturalHeight / img.naturalWidth,
-        maxClipWidth: landscape ? img.naturalHeight / img.naturalWidth : 1,
-        maxClipHeight: landscape ? 1 : img.naturalWidth / img.naturalHeight,
-      };
-      setImageInfo(info);
-      onChange({ left: 0, top: 0, width: info.maxClipWidth });
-      if (imageElementCallback != null) {
-        imageElementCallback(img);
-      }
-    },
-    [onChange, imageElementCallback],
-  );
-
-  const onPointerDown = React.useCallback(
-    (e: React.PointerEvent) => {
-      if (oldState != null) return;
-      e.currentTarget.setPointerCapture(e.pointerId);
-      setOldState({
-        x: e.clientX,
-        y: e.clientY,
-        clip: c,
-        pointerId: e.pointerId,
-      });
-    },
-    [oldState, c],
-  );
-
-  const onPointerUp = React.useCallback(
-    (e: React.PointerEvent) => {
-      if (oldState == null || oldState.pointerId !== e.pointerId) return;
-      e.currentTarget.releasePointerCapture(e.pointerId);
-      setOldState(null);
-    },
-    [oldState],
-  );
-
-  const onPointerMove = React.useCallback(
-    (e: React.PointerEvent) => {
-      if (oldState == null) return;
-
-      const oldClip = oldState.clip;
-
-      const movement = { x: e.clientX - oldState.x, y: e.clientY - oldState.y };
-
-      const { current: imgElement } = imgElementRef;
-
-      if (imgElement == null) throw new UiLogicError("Image element is null.");
-
-      const moveRatio = {
-        x: movement.x / imgElement.width,
-        y: movement.y / imgElement.height,
-      };
-
-      const newRatio = {
-        x: oldClip.left + moveRatio.x,
-        y: oldClip.top + moveRatio.y,
-      };
-      if (newRatio.x < 0) {
-        newRatio.x = 0;
-      } else if (newRatio.x > 1 - oldClip.width) {
-        newRatio.x = 1 - oldClip.width;
-      }
-      if (newRatio.y < 0) {
-        newRatio.y = 0;
-      } else if (newRatio.y > 1 - oldClip.height) {
-        newRatio.y = 1 - oldClip.height;
-      }
-
-      onChange({ left: newRatio.x, top: newRatio.y, width: oldClip.width });
-    },
-    [oldState, onChange],
-  );
-
-  const onHandlerPointerMove = React.useCallback(
-    (e: React.PointerEvent) => {
-      if (oldState == null) return;
-
-      const oldClip = oldState.clip;
-
-      const movement = { x: e.clientX - oldState.x, y: e.clientY - oldState.y };
-
-      const ratio = imageInfo == null ? 1 : imageInfo.ratio;
-
-      const { current: imgElement } = imgElementRef;
-
-      if (imgElement == null) throw new UiLogicError("Image element is null.");
-
-      const moveRatio = {
-        x: movement.x / imgElement.width,
-        y: movement.x / imgElement.width / ratio,
-      };
-
-      const newRatio = {
-        x: oldClip.width + moveRatio.x,
-        y: oldClip.height + moveRatio.y,
-      };
-
-      const maxRatio = {
-        x: Math.min(1 - oldClip.left, newRatio.x),
-        y: Math.min(1 - oldClip.top, newRatio.y),
-      };
-
-      const maxWidthRatio = Math.min(maxRatio.x, maxRatio.y * ratio);
-
-      let newWidth;
-      if (newRatio.x < 0) {
-        newWidth = 0;
-      } else if (newRatio.x > maxWidthRatio) {
-        newWidth = maxWidthRatio;
-      } else {
-        newWidth = newRatio.x;
-      }
-
-      onChange({ left: oldClip.left, top: oldClip.top, width: newWidth });
-    },
-    [imageInfo, oldState, onChange],
-  );
-
-  const toPercentage = (n: number): string => `${n}%`;
-
-  // fuck!!! I just can't find a better way to implement this in pure css
-  const containerStyle: React.CSSProperties = (() => {
-    if (imageInfo == null) {
-      return { width: "100%", paddingTop: "100%", height: 0 };
-    } else {
-      if (imageInfo.ratio > 1) {
-        return {
-          width: toPercentage(100 / imageInfo.ratio),
-          paddingTop: "100%",
-          height: 0,
-        };
-      } else {
-        return {
-          width: "100%",
-          paddingTop: toPercentage(100 * imageInfo.ratio),
-          height: 0,
-        };
-      }
-    }
-  })();
-
-  return (
-    <div
-      className={classnames("image-cropper-container", className)}
-      style={containerStyle}
-    >
-      <BlobImage
-        imgRef={onImageRef}
-        src={image}
-        onLoad={onImageLoad}
-        alt="to crop"
-      />
-      <div className="image-cropper-mask-container">
-        <div
-          className="image-cropper-mask"
-          style={{
-            left: toPercentage(c.left * 100),
-            top: toPercentage(c.top * 100),
-            width: toPercentage(c.width * 100),
-            height: toPercentage(c.height * 100),
-          }}
-          onPointerMove={onPointerMove}
-          onPointerDown={onPointerDown}
-          onPointerUp={onPointerUp}
-        />
-      </div>
-      <div
-        className="image-cropper-handler"
-        style={{
-          left: `calc(${(c.left + c.width) * 100}% - 15px)`,
-          top: `calc(${(c.top + c.height) * 100}% - 15px)`,
-        }}
-        onPointerMove={onHandlerPointerMove}
-        onPointerDown={onPointerDown}
-        onPointerUp={onPointerUp}
-      />
-    </div>
-  );
-};
-
-export default ImageCropper;
-
-export function applyClipToImage(
-  image: HTMLImageElement,
-  clip: Clip,
-  mimeType: string,
-): Promise<Blob> {
-  return new Promise((resolve, reject) => {
-    const naturalSize = {
-      width: image.naturalWidth,
-      height: image.naturalHeight,
-    };
-    const clipArea = {
-      x: naturalSize.width * clip.left,
-      y: naturalSize.height * clip.top,
-      length: naturalSize.width * clip.width,
-    };
-
-    const canvas = document.createElement("canvas");
-    canvas.width = clipArea.length;
-    canvas.height = clipArea.length;
-    const context = canvas.getContext("2d");
-
-    if (context == null) throw new Error("Failed to create context.");
-
-    context.drawImage(
-      image,
-      clipArea.x,
-      clipArea.y,
-      clipArea.length,
-      clipArea.length,
-      0,
-      0,
-      clipArea.length,
-      clipArea.length,
-    );
-
-    canvas.toBlob((blob) => {
-      if (blob == null) {
-        reject(new Error("canvas.toBlob returns null"));
-      } else {
-        resolve(blob);
-      }
-    }, mimeType);
-  });
-}
diff --git a/FrontEnd/src/views/common/LoadFailReload.tsx b/FrontEnd/src/views/common/LoadFailReload.tsx
deleted file mode 100644
index 81ba1f67..00000000
--- a/FrontEnd/src/views/common/LoadFailReload.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-import * as React from "react";
-import { Trans } from "react-i18next";
-
-export interface LoadFailReloadProps {
-  className?: string;
-  style?: React.CSSProperties;
-  onReload: () => void;
-}
-
-const LoadFailReload: React.FC<LoadFailReloadProps> = ({
-  onReload,
-  className,
-  style,
-}) => {
-  return (
-    <Trans
-      i18nKey="loadFailReload"
-      parent="div"
-      className={className}
-      style={style}
-    >
-      0
-      <a
-        href="#"
-        onClick={(e) => {
-          onReload();
-          e.preventDefault();
-        }}
-      >
-        1
-      </a>
-      2
-    </Trans>
-  );
-};
-
-export default LoadFailReload;
diff --git a/FrontEnd/src/views/common/LoadingPage.tsx b/FrontEnd/src/views/common/LoadingPage.tsx
deleted file mode 100644
index 35ee1aa8..00000000
--- a/FrontEnd/src/views/common/LoadingPage.tsx
+++ /dev/null
@@ -1,13 +0,0 @@
-import * as React from "react";
-
-import Spinner from "./Spinner";
-
-const LoadingPage: React.FC = () => {
-  return (
-    <div className="position-fixed w-100 h-100 d-flex justify-content-center align-items-center">
-      <Spinner />
-    </div>
-  );
-};
-
-export default LoadingPage;
diff --git a/FrontEnd/src/views/common/Page.tsx b/FrontEnd/src/views/common/Page.tsx
deleted file mode 100644
index 86fdb2f5..00000000
--- a/FrontEnd/src/views/common/Page.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-import { ComponentPropsWithoutRef, Ref } from "react";
-import classNames from "classnames";
-
-interface PageProps extends ComponentPropsWithoutRef<"div"> {
-  noTopPadding?: boolean;
-  pageRef?: Ref<HTMLDivElement>;
-}
-
-export default function Page({ noTopPadding, pageRef, className, children }: PageProps) {
-  return (
-    <div ref={pageRef} className={classNames(className, "cru-page", noTopPadding && "cru-page-no-top-padding")}>
-      {children}
-    </div>
-  );
-}
diff --git a/FrontEnd/src/views/common/SearchInput.css b/FrontEnd/src/views/common/SearchInput.css
deleted file mode 100644
index f0503016..00000000
--- a/FrontEnd/src/views/common/SearchInput.css
+++ /dev/null
@@ -1,8 +0,0 @@
-.cru-search-input {
-  display: flex;
-  flex-wrap: wrap;
-}
-
-.cru-search-input-input {
-  width: 100%;
-}
diff --git a/FrontEnd/src/views/common/SearchInput.tsx b/FrontEnd/src/views/common/SearchInput.tsx
deleted file mode 100644
index e3216b86..00000000
--- a/FrontEnd/src/views/common/SearchInput.tsx
+++ /dev/null
@@ -1,50 +0,0 @@
-import classNames from "classnames";
-
-import { useC, Text } from "./common";
-import LoadingButton from "./button/LoadingButton";
-
-import "./SearchInput.css";
-
-interface SearchInputProps {
-  value: string;
-  onChange: (value: string) => void;
-  onButtonClick: () => void;
-  loading?: boolean;
-  className?: string;
-  buttonText?: Text;
-}
-
-export default function SearchInput({
-  value,
-  onChange,
-  onButtonClick,
-  loading,
-  className,
-  buttonText,
-}: SearchInputProps) {
-  const c = useC();
-
-  return (
-    <div className={classNames("cru-search-input", className)}>
-      <input
-        type="text"
-        className="cru-search-input-input"
-        value={value}
-        onChange={(event) => {
-          const { value } = event.currentTarget;
-          onChange(value);
-        }}
-        onKeyDown={(event) => {
-          if (event.key === "Enter") {
-            onButtonClick();
-            event.preventDefault();
-          }
-        }}
-      />
-
-      <LoadingButton loading={loading} onClick={onButtonClick}>
-        {c(buttonText ?? "search")}
-      </LoadingButton>
-    </div>
-  );
-}
diff --git a/FrontEnd/src/views/common/Skeleton.css b/FrontEnd/src/views/common/Skeleton.css
deleted file mode 100644
index a571eead..00000000
--- a/FrontEnd/src/views/common/Skeleton.css
+++ /dev/null
@@ -1,14 +0,0 @@
-.cru-skeleton {
-  padding: 0 1em;
-}
-
-.cru-skeleton-line {
-  height: 1em;
-  background-color: hsl(0, 0%, 90%);
-  margin: 0.7em 0;
-  border-radius: 0.2em;
-}
-
-.cru-skeleton-line.last {
-  width: 50%;
-}
diff --git a/FrontEnd/src/views/common/Skeleton.tsx b/FrontEnd/src/views/common/Skeleton.tsx
deleted file mode 100644
index 3b149db9..00000000
--- a/FrontEnd/src/views/common/Skeleton.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import * as React from "react";
-import classnames from "classnames";
-import range from "lodash/range";
-
-import "./Skeleton.css";
-
-export interface SkeletonProps {
-  lineNumber?: number;
-  className?: string;
-  style?: React.CSSProperties;
-}
-
-const Skeleton: React.FC<SkeletonProps> = (props) => {
-  const { lineNumber: lineNumberProps, className, style } = props;
-  const lineNumber = lineNumberProps ?? 3;
-
-  return (
-    <div className={classnames(className, "cru-skeleton")} style={style}>
-      {range(lineNumber).map((i) => (
-        <div
-          key={i}
-          className={classnames(
-            "cru-skeleton-line",
-            i === lineNumber - 1 && "last"
-          )}
-        />
-      ))}
-    </div>
-  );
-};
-
-export default Skeleton;
diff --git a/FrontEnd/src/views/common/Spinner.css b/FrontEnd/src/views/common/Spinner.css
deleted file mode 100644
index a1de68d2..00000000
--- a/FrontEnd/src/views/common/Spinner.css
+++ /dev/null
@@ -1,13 +0,0 @@
-@keyframes cru-spinner-animation {
-  from {
-    transform: scale(0,0);
-  }
-}
-
-.cru-spinner {
-  display: inline-block;
-  animation: cru-spinner-animation 0.5s infinite alternate;
-  background-color: currentColor;
-  border-radius: 50%;
-  transform-origin: center;
-}
diff --git a/FrontEnd/src/views/common/Spinner.tsx b/FrontEnd/src/views/common/Spinner.tsx
deleted file mode 100644
index ec0c2c35..00000000
--- a/FrontEnd/src/views/common/Spinner.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import { CSSProperties } from "react";
-import classnames from "classnames";
-
-import { ThemeColor } from "./common";
-
-import "./Spinner.css";
-
-export interface SpinnerProps {
-  size?: "sm" | "md" | "lg" | number | string;
-  color?: ThemeColor;
-  className?: string;
-  style?: CSSProperties;
-}
-
-export default function Spinner(props: SpinnerProps) {
-  const { size, color, className, style } = props;
-  const calculatedSize =
-    size === "sm"
-      ? "18px"
-      : size === "md"
-      ? "30px"
-      : size === "lg"
-      ? "42px"
-      : typeof size === "number"
-      ? size
-      : size == null
-      ? "20px"
-      : size;
-
-  return (
-    <span
-      className={classnames("cru-spinner", color && `cru-${color}`, className)}
-      style={{ width: calculatedSize, height: calculatedSize, ...style }}
-    />
-  );
-}
diff --git a/FrontEnd/src/views/common/TimelineLogo.tsx b/FrontEnd/src/views/common/TimelineLogo.tsx
deleted file mode 100644
index e06ed0f5..00000000
--- a/FrontEnd/src/views/common/TimelineLogo.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import { SVGAttributes } from "react";
-import * as React from "react";
-
-export interface TimelineLogoProps extends SVGAttributes<SVGElement> {
-  color?: string;
-}
-
-const TimelineLogo: React.FC<TimelineLogoProps> = (props) => {
-  const { color, ...forwardProps } = props;
-  const coercedColor = color ?? "currentcolor";
-  return (
-    <svg
-      className={props.className}
-      viewBox="0 0 100 100"
-      fill="none"
-      strokeWidth="12"
-      stroke={coercedColor}
-      {...forwardProps}
-    >
-      <line x1="50" y1="0" x2="50" y2="25" />
-      <circle cx="50" cy="50" r="22" />
-      <line x1="50" y1="75" x2="50" y2="100" />
-    </svg>
-  );
-};
-
-export default TimelineLogo;
diff --git a/FrontEnd/src/views/common/alert/AlertHost.tsx b/FrontEnd/src/views/common/alert/AlertHost.tsx
deleted file mode 100644
index 42074781..00000000
--- a/FrontEnd/src/views/common/alert/AlertHost.tsx
+++ /dev/null
@@ -1,113 +0,0 @@
-import * as React from "react";
-import without from "lodash/without";
-import { useTranslation } from "react-i18next";
-import classNames from "classnames";
-
-import { alertService, AlertInfoEx, AlertInfo } from "@/services/alert";
-import { convertI18nText } from "@/common";
-
-import IconButton from "../button/IconButton";
-
-import "./alert.css";
-
-interface AutoCloseAlertProps {
-  alert: AlertInfo;
-  close: () => void;
-}
-
-export const AutoCloseAlert: React.FC<AutoCloseAlertProps> = (props) => {
-  const { alert, close } = props;
-  const { dismissTime } = alert;
-
-  const { t } = useTranslation();
-
-  const timerTag = React.useRef<number | null>(null);
-  const closeHandler = React.useRef<(() => void) | null>(null);
-
-  React.useEffect(() => {
-    closeHandler.current = close;
-  }, [close]);
-
-  React.useEffect(() => {
-    const tag =
-      dismissTime === "never"
-        ? null
-        : typeof dismissTime === "number"
-        ? window.setTimeout(() => closeHandler.current?.(), dismissTime)
-        : window.setTimeout(() => closeHandler.current?.(), 5000);
-    timerTag.current = tag;
-    return () => {
-      if (tag != null) {
-        window.clearTimeout(tag);
-      }
-    };
-  }, [dismissTime]);
-
-  const cancelTimer = (): void => {
-    const { current: tag } = timerTag;
-    if (tag != null) {
-      window.clearTimeout(tag);
-    }
-  };
-
-  return (
-    <div
-      className={classNames(
-        "m-3 cru-alert",
-        "cru-" + (alert.type ?? "primary")
-      )}
-      onClick={cancelTimer}
-    >
-      <div className="cru-alert-content">
-        {(() => {
-          const { message, customMessage } = alert;
-          if (customMessage != null) {
-            return customMessage;
-          } else {
-            return convertI18nText(message, t);
-          }
-        })()}
-      </div>
-      <div className="cru-alert-close-button-container">
-        <IconButton
-          icon="x"
-          className="cru-alert-close-button"
-          onClick={close}
-        />
-      </div>
-    </div>
-  );
-};
-
-const AlertHost: React.FC = () => {
-  const [alerts, setAlerts] = React.useState<AlertInfoEx[]>([]);
-
-  React.useEffect(() => {
-    const consume = (alert: AlertInfoEx): void => {
-      setAlerts((old) => [...old, alert]);
-    };
-
-    alertService.registerConsumer(consume);
-    return () => {
-      alertService.unregisterConsumer(consume);
-    };
-  }, []);
-
-  return (
-    <div className="alert-container">
-      {alerts.map((alert) => {
-        return (
-          <AutoCloseAlert
-            key={alert.id}
-            alert={alert}
-            close={() => {
-              setAlerts((old) => without(old, alert));
-            }}
-          />
-        );
-      })}
-    </div>
-  );
-};
-
-export default AlertHost;
diff --git a/FrontEnd/src/views/common/alert/alert.css b/FrontEnd/src/views/common/alert/alert.css
deleted file mode 100644
index 54c2b87f..00000000
--- a/FrontEnd/src/views/common/alert/alert.css
+++ /dev/null
@@ -1,33 +0,0 @@
-.alert-container {
-  position: fixed;
-  z-index: 1040;
-}
-
-.cru-alert {
-  border-radius: 5px;
-  border: var(--cru-key-color) 1px solid;
-  color: var(--cru-key-t-color);
-  background-color: var(--cru-key-b1-color);
-
-  display: flex;
-  overflow: hidden;
-}
-
-.cru-alert-content {
-  padding: 0.5em 2em;
-}
-
-.cru-alert-close-button-container {
-  flex-shrink: 0;
-  margin-left: auto;
-  width: 2em;
-  text-align: center;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  background-color: var(--cru-key-t-color);
-}
-
-.cru-alert-close-button {
-  color: var(--cru-key-color);
-}
diff --git a/FrontEnd/src/views/common/breakpoints.ts b/FrontEnd/src/views/common/breakpoints.ts
deleted file mode 100644
index fb281610..00000000
--- a/FrontEnd/src/views/common/breakpoints.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export const breakpoints = {
-  sm: 576,
-} as const;
diff --git a/FrontEnd/src/views/common/button/Button.css b/FrontEnd/src/views/common/button/Button.css
deleted file mode 100644
index 1da70f0e..00000000
--- a/FrontEnd/src/views/common/button/Button.css
+++ /dev/null
@@ -1,64 +0,0 @@
-.cru-button {
-  font-size: 1rem;
-  padding: 0.4em 0.8em;
-  transition: all 0.3s;
-  border-radius: 0.2em;
-  border: 1px solid;
-  cursor: pointer;
-}
-
-.cru-button:not(.outline) {
-  color: var(--cru-push-button-text-color);
-  background-color: var(--cru-clickable-normal-color);
-  border-color: var(--cru-clickable-normal-color);
-}
-
-.cru-button:not(.outline):hover {
-  background-color: var(--cru-clickable-hover-color);
-  border-color: var(--cru-clickable-hover-color);
-}
-
-.cru-button:not(.outline):focus {
-  background-color: var(--cru-clickable-focus-color);
-  border-color: var(--cru-clickable-focus-color);
-}
-
-.cru-button:not(.outline):active {
-  background-color: var(--cru-clickable-active-color);
-  border-color: var(--cru-clickable-active-color);
-}
-
-.cru-button:not(.outline):disabled {
-  color: var(--cru-push-button-disabled-text-color);
-  background-color: var(--cru-push-button-disabled-color);
-  border-color: var(--cru-push-button-disabled-color);
-  cursor: auto;
-}
-
-
-.cru-button.outline {
-  color: var(--cru-clickable-normal-color);
-  border-color: var(--cru-clickable-normal-color);
-  background-color: transparent;
-}
-
-.cru-button.outline:hover {
-  color: var(--cru-clickable-hover-color);
-  border-color: var(--cru-clickable-hover-color);
-}
-
-.cru-button.outline:focus {
-  color: var(--cru-clickable-focus-color);
-  border-color: var(--cru-clickable-focus-color);
-}
-
-.cru-button.outline:active {
-  color: var(--cru-clickable-active-color);
-  border-color: var(--cru-clickable-active-color);
-}
-
-.cru-button.outline:disabled {
-  color: var(--cru-clickable-disabled-color);
-  border-color: var(--cru-clickable-disabled-color);
-  cursor: auto;
-}
diff --git a/FrontEnd/src/views/common/button/Button.tsx b/FrontEnd/src/views/common/button/Button.tsx
deleted file mode 100644
index 6c38e130..00000000
--- a/FrontEnd/src/views/common/button/Button.tsx
+++ /dev/null
@@ -1,46 +0,0 @@
-import { ComponentPropsWithoutRef, Ref } from "react";
-import classNames from "classnames";
-
-import { Text, useC, ThemeColor } from "../common";
-
-import "./Button.css";
-
-interface ButtonProps extends ComponentPropsWithoutRef<"button"> {
-  color?: ThemeColor;
-  text?: Text;
-  outline?: boolean;
-  buttonRef?: Ref<HTMLButtonElement> | null;
-}
-
-export default function Button(props: ButtonProps) {
-  const {
-    buttonRef,
-    color,
-    text,
-    outline,
-    className,
-    children,
-    ...otherProps
-  } = props;
-
-  if (text != null && children != null) {
-    console.warn("You can't set both text and children props.");
-  }
-
-  const c = useC();
-
-  return (
-    <button
-      ref={buttonRef}
-      className={classNames(
-        "cru-button",
-        `cru-clickable-${color ?? "primary"}`,
-        outline && "outline",
-        className,
-      )}
-      {...otherProps}
-    >
-      {text != null ? c(text) : children}
-    </button>
-  );
-}
diff --git a/FrontEnd/src/views/common/button/ButtonRow.css b/FrontEnd/src/views/common/button/ButtonRow.css
deleted file mode 100644
index e69de29b..00000000
diff --git a/FrontEnd/src/views/common/button/ButtonRow.tsx b/FrontEnd/src/views/common/button/ButtonRow.tsx
deleted file mode 100644
index eea60cc4..00000000
--- a/FrontEnd/src/views/common/button/ButtonRow.tsx
+++ /dev/null
@@ -1,62 +0,0 @@
-import { ComponentPropsWithoutRef, Ref } from "react";
-import classNames from "classnames";
-
-import Button from "./Button";
-import FlatButton from "./FlatButton";
-import IconButton from "./IconButton";
-import LoadingButton from "./LoadingButton";
-
-import "./ButtonRow.css";
-
-type ButtonRowButton = (
-  | {
-      type: "normal";
-      props: ComponentPropsWithoutRef<typeof Button>;
-    }
-  | {
-      type: "flat";
-      props: ComponentPropsWithoutRef<typeof FlatButton>;
-    }
-  | {
-      type: "icon";
-      props: ComponentPropsWithoutRef<typeof IconButton>;
-    }
-  | { type: "loading"; props: ComponentPropsWithoutRef<typeof LoadingButton> }
-) & { key: string | number };
-
-interface ButtonRowProps {
-  className?: string;
-  containerRef?: Ref<HTMLDivElement>;
-  buttons: ButtonRowButton[];
-  buttonsClassName?: string;
-}
-
-export default function ButtonRow({
-  className,
-  containerRef,
-  buttons,
-  buttonsClassName,
-}: ButtonRowProps) {
-  return (
-    <div ref={containerRef} className={classNames("cru-button-row", className)}>
-      {buttons.map((button) => {
-        const { type, key, props } = button;
-        const newClassName = classNames(props.className, buttonsClassName);
-        switch (type) {
-          case "normal":
-            return <Button key={key} {...props} className={newClassName} />;
-          case "flat":
-            return <FlatButton key={key} {...props} className={newClassName} />;
-          case "icon":
-            return <IconButton key={key} {...props} className={newClassName} />;
-          case "loading":
-            return (
-              <LoadingButton key={key} {...props} className={newClassName} />
-            );
-          default:
-            throw new Error();
-        }
-      })}
-    </div>
-  );
-}
diff --git a/FrontEnd/src/views/common/button/ButtonRowV2.tsx b/FrontEnd/src/views/common/button/ButtonRowV2.tsx
deleted file mode 100644
index 3467ad52..00000000
--- a/FrontEnd/src/views/common/button/ButtonRowV2.tsx
+++ /dev/null
@@ -1,143 +0,0 @@
-import { ComponentPropsWithoutRef, Ref } from "react";
-import classNames from "classnames";
-
-import Button from "./Button";
-import FlatButton from "./FlatButton";
-import IconButton from "./IconButton";
-import LoadingButton from "./LoadingButton";
-
-import "./ButtonRow.css";
-import { Text, ThemeColor } from "../common";
-
-interface ButtonRowV2ButtonBase {
-  key: string | number;
-  action?: "primary" | "secondary";
-  color?: ThemeColor;
-  disabled?: boolean;
-  onClick?: () => void;
-}
-
-interface ButtonRowV2ButtonWithNoType extends ButtonRowV2ButtonBase {
-  type?: undefined | null;
-  text: Text;
-  outline?: boolean;
-  props?: ComponentPropsWithoutRef<typeof Button>;
-}
-
-interface ButtonRowV2NormalButton extends ButtonRowV2ButtonBase {
-  type: "normal";
-  text: Text;
-  outline?: boolean;
-  props?: ComponentPropsWithoutRef<typeof Button>;
-}
-
-interface ButtonRowV2FlatButton extends ButtonRowV2ButtonBase {
-  type: "flat";
-  text: Text;
-  props?: ComponentPropsWithoutRef<typeof FlatButton>;
-}
-
-interface ButtonRowV2IconButton extends ButtonRowV2ButtonBase {
-  type: "icon";
-  icon: string;
-  props?: ComponentPropsWithoutRef<typeof IconButton>;
-}
-
-interface ButtonRowV2LoadingButton extends ButtonRowV2ButtonBase {
-  type: "loading";
-  text: Text;
-  loading?: boolean;
-  props?: ComponentPropsWithoutRef<typeof LoadingButton>;
-}
-
-type ButtonRowV2Button =
-  | ButtonRowV2ButtonWithNoType
-  | ButtonRowV2NormalButton
-  | ButtonRowV2FlatButton
-  | ButtonRowV2IconButton
-  | ButtonRowV2LoadingButton;
-
-interface ButtonRowV2Props {
-  className?: string;
-  containerRef?: Ref<HTMLDivElement>;
-  buttons: ButtonRowV2Button[];
-  buttonsClassName?: string;
-}
-
-export default function ButtonRowV2({
-  className,
-  containerRef,
-  buttons,
-  buttonsClassName,
-}: ButtonRowV2Props) {
-  return (
-    <div ref={containerRef} className={classNames("cru-button-row", className)}>
-      {buttons.map((button) => {
-        const { key, action, color, disabled, onClick } = button;
-
-        const realAction = action ?? "primary";
-        const realColor =
-          color ?? (realAction === "primary" ? "primary" : "secondary");
-
-        const commonProps = { key, color: realColor, disabled, onClick };
-        const newClassName = classNames(
-          button.props?.className,
-          buttonsClassName,
-        );
-
-        switch (button.type) {
-          case null:
-          case undefined:
-          case "normal": {
-            const { text, outline, props } = button;
-            return (
-              <Button
-                {...commonProps}
-                text={text}
-                outline={outline ?? realAction !== "primary"}
-                {...props}
-                className={newClassName}
-              />
-            );
-          }
-          case "flat": {
-            const { text, props } = button;
-            return (
-              <FlatButton
-                {...commonProps}
-                text={text}
-                {...props}
-                className={newClassName}
-              />
-            );
-          }
-          case "icon": {
-            const { icon, props } = button;
-            return (
-              <IconButton
-                {...commonProps}
-                icon={icon}
-                {...props}
-                className={newClassName}
-              />
-            );
-          }
-          case "loading": {
-            const { text, loading, props } = button;
-            return (
-              <LoadingButton
-                {...commonProps}
-                text={text}
-                loading={loading}
-                {...props}
-                className={newClassName}
-              />
-            );
-          }
-          default:
-            throw new Error();
-        }
-      })}
-    </div>
-  );
-}
diff --git a/FrontEnd/src/views/common/button/FlatButton.css b/FrontEnd/src/views/common/button/FlatButton.css
deleted file mode 100644
index 2050946c..00000000
--- a/FrontEnd/src/views/common/button/FlatButton.css
+++ /dev/null
@@ -1,27 +0,0 @@
-.cru-flat-button {
-  font-size: 1rem;
-  padding: 0.4em 0.8em;
-  transition: all 0.5s;
-  border-radius: 0.2em;
-  background-color: var(--cru-clickable-grayscale-normal-color);
-  border: 1px none;
-  color: var(--cru-clickable-normal-color);
-  cursor: pointer;
-}
-
-.cru-flat-button:hover {
-  background-color: var(--cru-clickable-grayscale-hover-color);
-}
-
-.cru-flat-button:focus {
-  background-color: var(--cru-clickable-grayscale-focus-color);
-}
-
-.cru-flat-button:active {
-  background-color: var(--cru-clickable-grayscale-active-color);
-}
-
-.cru-flat-button:disabled {
-  color: var(--cru-clickable-disabled-color);
-  cursor: auto;
-}
\ No newline at end of file
diff --git a/FrontEnd/src/views/common/button/FlatButton.tsx b/FrontEnd/src/views/common/button/FlatButton.tsx
deleted file mode 100644
index 9f074dd6..00000000
--- a/FrontEnd/src/views/common/button/FlatButton.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import { ComponentPropsWithoutRef, Ref } from "react";
-import classNames from "classnames";
-
-import { Text, useC, ThemeColor } from "../common";
-
-import "./FlatButton.css";
-
-interface FlatButtonProps extends ComponentPropsWithoutRef<"button"> {
-  color?: ThemeColor;
-  text?: Text;
-  buttonRef?: Ref<HTMLButtonElement> | null;
-}
-
-export default function FlatButton(props: FlatButtonProps) {
-  const { color, text, className, children, buttonRef, ...otherProps } = props;
-
-  if (text != null && children != null) {
-    console.warn("You can't set both text and children props.");
-  }
-
-  const c = useC();
-
-  return (
-    <button
-      ref={buttonRef}
-      className={classNames(
-        "cru-flat-button",
-        `cru-clickable-${color ?? "primary"}`,
-        className,
-      )}
-      {...otherProps}
-    >
-      {text != null ? c(text) : children}
-    </button>
-  );
-}
diff --git a/FrontEnd/src/views/common/button/IconButton.css b/FrontEnd/src/views/common/button/IconButton.css
deleted file mode 100644
index a3747201..00000000
--- a/FrontEnd/src/views/common/button/IconButton.css
+++ /dev/null
@@ -1,30 +0,0 @@
-.cru-icon-button {
-  color: var(--cru-clickable-normal-color);
-  font-size: 1.4rem;
-  background: none;
-  border: none;
-  transition: all 0.5s;
-  cursor: pointer;
-  user-select: none;
-}
-
-.cru-icon-button:hover {
-  color: var(--cru-clickable-hover-color);
-}
-
-.cru-icon-button:focus {
-  color: var(--cru-clickable-focus-color);
-}
-
-.cru-icon-button:active {
-  color: var(--cru-clickable-active-color);
-}
-
-.cru-flat-button:disabled {
-  color: var(--cru-clickable-disabled-color);
-  cursor: auto;
-}
-
-.cru-icon-button.large {
-  font-size: 1.6rem;
-}
diff --git a/FrontEnd/src/views/common/button/IconButton.tsx b/FrontEnd/src/views/common/button/IconButton.tsx
deleted file mode 100644
index 95c58887..00000000
--- a/FrontEnd/src/views/common/button/IconButton.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import { ComponentPropsWithoutRef } from "react";
-import classNames from "classnames";
-
-import { ThemeColor } from "../common";
-
-import "./IconButton.css";
-
-interface IconButtonProps extends ComponentPropsWithoutRef<"i"> {
-  icon: string;
-  color?: ThemeColor | "grayscale";
-  large?: boolean;
-  disabled?: boolean; // TODO: Not implemented
-}
-
-export default function IconButton(props: IconButtonProps) {
-  const { icon, color, className, large, ...otherProps } = props;
-
-  return (
-    <button
-      className={classNames(
-        "cru-icon-button",
-        `cru-clickable-${color ?? "grayscale"}`,
-        large && "large",
-        "bi-" + icon,
-        className,
-      )}
-      {...otherProps}
-    />
-  );
-}
diff --git a/FrontEnd/src/views/common/button/LoadingButton.css b/FrontEnd/src/views/common/button/LoadingButton.css
deleted file mode 100644
index 23fadd3d..00000000
--- a/FrontEnd/src/views/common/button/LoadingButton.css
+++ /dev/null
@@ -1,13 +0,0 @@
-.cru-loading-button {
-  display: flex;
-  align-items: center;
-}
-
-.cru-loading-button-spinner {
-  margin-left: 0.5em;
-}
-
-.cru-loading-button-loading {
-  color: var(--cru-clickable-normal-color) !important;
-  border-color: var(--cru-clickable-normal-color) !important;
-}
\ No newline at end of file
diff --git a/FrontEnd/src/views/common/button/LoadingButton.tsx b/FrontEnd/src/views/common/button/LoadingButton.tsx
deleted file mode 100644
index 7e7d08e6..00000000
--- a/FrontEnd/src/views/common/button/LoadingButton.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-import classNames from "classnames";
-
-import { I18nText, ThemeColor, useC } from "../common";
-
-import Spinner from "../Spinner";
-
-import "./LoadingButton.css";
-
-interface LoadingButtonProps extends React.ComponentPropsWithoutRef<"button"> {
-  color?: ThemeColor;
-  text?: I18nText;
-  loading?: boolean;
-}
-
-export default function LoadingButton(props: LoadingButtonProps) {
-  const c = useC();
-
-  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.");
-  }
-
-  return (
-    <button
-      disabled={disabled || loading}
-      className={classNames(
-        "cru-button outline cru-loading-button",
-        `cru-clickable-${color ?? "primary"}`,
-        loading && "cru-loading-button-loading",
-        className,
-      )}
-      {...otherProps}
-    >
-      {text != null ? c(text) : children}
-      {loading && <Spinner className="cru-loading-button-spinner" />}
-    </button>
-  );
-}
diff --git a/FrontEnd/src/views/common/button/index.tsx b/FrontEnd/src/views/common/button/index.tsx
deleted file mode 100644
index b5aa5470..00000000
--- a/FrontEnd/src/views/common/button/index.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-import Button from "./Button";
-import FlatButton from "./FlatButton";
-import IconButton from "./IconButton";
-import LoadingButton from "./LoadingButton";
-import ButtonRow from "./ButtonRow";
-import ButtonRowV2 from "./ButtonRowV2";
-
-export {
-  Button,
-  FlatButton,
-  IconButton,
-  LoadingButton,
-  ButtonRow,
-  ButtonRowV2,
-};
diff --git a/FrontEnd/src/views/common/common.ts b/FrontEnd/src/views/common/common.ts
deleted file mode 100644
index 7af2643b..00000000
--- a/FrontEnd/src/views/common/common.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-export type { Text, I18nText } from "@/common";
-export { c, convertI18nText, useC } from "@/common";
-
-export const themeColors = [
-  "primary",
-  "secondary",
-  "danger",
-  "create",
-] as const;
-
-export type ThemeColor = (typeof themeColors)[number];
-
-export { breakpoints } from "./breakpoints";
-export { useMobile } from "./hooks";
diff --git a/FrontEnd/src/views/common/dialog/ConfirmDialog.css b/FrontEnd/src/views/common/dialog/ConfirmDialog.css
deleted file mode 100644
index e69de29b..00000000
diff --git a/FrontEnd/src/views/common/dialog/ConfirmDialog.tsx b/FrontEnd/src/views/common/dialog/ConfirmDialog.tsx
deleted file mode 100644
index dbbd15c6..00000000
--- a/FrontEnd/src/views/common/dialog/ConfirmDialog.tsx
+++ /dev/null
@@ -1,59 +0,0 @@
-import { useC, Text, ThemeColor } from "@/views/common/common";
-
-import Dialog from "./Dialog";
-import DialogContainer from "./DialogContainer";
-
-export default function ConfirmDialog({
-  open,
-  onClose,
-  onConfirm,
-  title,
-  body,
-  color,
-  bodyColor,
-}: {
-  open: boolean;
-  onClose: () => void;
-  onConfirm: () => void;
-  title: Text;
-  body: Text;
-  color?: ThemeColor;
-  bodyColor?: ThemeColor;
-}) {
-  const c = useC();
-
-  return (
-    <Dialog onClose={onClose} open={open}>
-      <DialogContainer
-        title={title}
-        titleColor={color ?? "danger"}
-        buttons={[
-          {
-            key: "cancel",
-            type: "normal",
-            props: {
-              text: "operationDialog.cancel",
-              color: "secondary",
-              outline: true,
-              onClick: onClose,
-            },
-          },
-          {
-            key: "confirm",
-            type: "normal",
-            props: {
-              text: "operationDialog.confirm",
-              color: "danger",
-              onClick: () => {
-                onConfirm();
-                onClose();
-              },
-            },
-          },
-        ]}
-      >
-        <div className={`cru-${bodyColor ?? "primary"}`}>{c(body)}</div>
-      </DialogContainer>
-    </Dialog>
-  );
-}
diff --git a/FrontEnd/src/views/common/dialog/Dialog.css b/FrontEnd/src/views/common/dialog/Dialog.css
deleted file mode 100644
index e4c61440..00000000
--- a/FrontEnd/src/views/common/dialog/Dialog.css
+++ /dev/null
@@ -1,60 +0,0 @@
-.cru-dialog-overlay {
-  position: fixed;
-  z-index: 1040;
-  left: 0;
-  top: 0;
-  right: 0;
-  bottom: 0;
-  display: flex;
-  align-items: center;
-  overflow: auto;
-}
-
-.cru-dialog-background {
-  position: absolute;
-  z-index: -1;
-  left: 0;
-  right: 0;
-  top: 0;
-  bottom: 0;
-  background-color: var(--cru-surface-dim-color);
-  opacity: 0.8;
-}
-
-.cru-dialog-container {
-  max-width: 100%;
-  min-width: 30vw;
-
-  margin: 2em auto;
-
-  border: var(--cru-key-container-color) 1px solid;
-  border-radius: 5px;
-  padding: 1.5em;
-  background-color: var(--cru-surface-color);
-}
-
-@media (min-width: 576px) {
-  .cru-dialog-container {
-    max-width: 800px;
-  }
-}
-
-.cru-dialog-enter .cru-dialog-container {
-  transform: scale(0, 0);
-  opacity: 0;
-  transform-origin: center;
-}
-
-.cru-dialog-enter-active .cru-dialog-container {
-  transform: scale(1, 1);
-  opacity: 1;
-  transition: transform 0.3s, opacity 0.3s;
-  transform-origin: center;
-}
-
-.cru-dialog-exit-active .cru-dialog-container {
-  transition: transform 0.3s, opacity 0.3s;
-  transform: scale(0, 0);
-  opacity: 0;
-  transform-origin: center;
-}
\ No newline at end of file
diff --git a/FrontEnd/src/views/common/dialog/Dialog.tsx b/FrontEnd/src/views/common/dialog/Dialog.tsx
deleted file mode 100644
index 2ff7bea8..00000000
--- a/FrontEnd/src/views/common/dialog/Dialog.tsx
+++ /dev/null
@@ -1,63 +0,0 @@
-import { ReactNode, useRef } from "react";
-import ReactDOM from "react-dom";
-import { CSSTransition } from "react-transition-group";
-import classNames from "classnames";
-
-import { ThemeColor } from "../common";
-
-import "./Dialog.css";
-
-const optionalPortalElement = document.getElementById("portal");
-if (optionalPortalElement == null) {
-  throw new Error("Portal element not found");
-}
-const portalElement = optionalPortalElement;
-
-interface DialogProps {
-  open: boolean;
-  onClose: () => void;
-  color?: ThemeColor;
-  children?: ReactNode;
-  disableCloseOnClickOnOverlay?: boolean;
-}
-
-export default function Dialog({
-  open,
-  onClose,
-  color,
-  children,
-  disableCloseOnClickOnOverlay,
-}: DialogProps) {
-  color = color ?? "primary";
-
-  const nodeRef = useRef(null);
-
-  return ReactDOM.createPortal(
-    <CSSTransition
-      nodeRef={nodeRef}
-      mountOnEnter
-      unmountOnExit
-      in={open}
-      timeout={300}
-      classNames="cru-dialog"
-    >
-      <div
-        ref={nodeRef}
-        className={classNames("cru-dialog-overlay", `cru-${color}`)}
-      >
-        <div
-          className="cru-dialog-background"
-          onClick={
-            disableCloseOnClickOnOverlay
-              ? undefined
-              : () => {
-                  onClose();
-                }
-          }
-        />
-        <div className="cru-dialog-container">{children}</div>
-      </div>
-    </CSSTransition>,
-    portalElement,
-  );
-}
diff --git a/FrontEnd/src/views/common/dialog/DialogContainer.css b/FrontEnd/src/views/common/dialog/DialogContainer.css
deleted file mode 100644
index fbb18e0d..00000000
--- a/FrontEnd/src/views/common/dialog/DialogContainer.css
+++ /dev/null
@@ -1,20 +0,0 @@
-.cru-dialog-container-title {
-  font-size: 1.2em;
-  font-weight: bold;
-  color: var(--cru-key-color);
-  margin-bottom: 0.5em;
-}
-
-
-.cru-dialog-container-hr {
-  margin: 1em 0;
-}
-
-.cru-dialog-container-button-row {
-  display: flex;
-  justify-content: flex-end;
-}
-
-.cru-dialog-container-button {
-  margin-left: 1em;
-}
\ No newline at end of file
diff --git a/FrontEnd/src/views/common/dialog/DialogContainer.tsx b/FrontEnd/src/views/common/dialog/DialogContainer.tsx
deleted file mode 100644
index afee2669..00000000
--- a/FrontEnd/src/views/common/dialog/DialogContainer.tsx
+++ /dev/null
@@ -1,95 +0,0 @@
-import { ComponentProps, Ref, ReactNode } from "react";
-import classNames from "classnames";
-
-import { ThemeColor, Text, useC } from "../common";
-import { ButtonRow, ButtonRowV2 } from "../button";
-
-import "./DialogContainer.css";
-
-interface DialogContainerBaseProps {
-  className?: string;
-  title: Text;
-  titleColor?: ThemeColor;
-  titleClassName?: string;
-  titleRef?: Ref<HTMLDivElement>;
-  bodyContainerClassName?: string;
-  bodyContainerRef?: Ref<HTMLDivElement>;
-  buttonsClassName?: string;
-  buttonsContainerRef?: ComponentProps<typeof ButtonRow>["containerRef"];
-  children: ReactNode;
-}
-
-interface DialogContainerWithButtonsProps extends DialogContainerBaseProps {
-  buttons: ComponentProps<typeof ButtonRow>["buttons"];
-}
-
-interface DialogContainerWithButtonsV2Props extends DialogContainerBaseProps {
-  buttonsV2: ComponentProps<typeof ButtonRowV2>["buttons"];
-}
-
-type DialogContainerProps =
-  | DialogContainerWithButtonsProps
-  | DialogContainerWithButtonsV2Props;
-
-export default function DialogContainer(props: DialogContainerProps) {
-  const {
-    className,
-    title,
-    titleColor,
-    titleClassName,
-    titleRef,
-    bodyContainerClassName,
-    bodyContainerRef,
-    buttonsClassName,
-    buttonsContainerRef,
-    children,
-  } = props;
-
-  const c = useC();
-
-  return (
-    <div className={classNames(className)}>
-      <div
-        ref={titleRef}
-        className={classNames(
-          `cru-dialog-container-title cru-${titleColor ?? "primary"}`,
-          titleClassName,
-        )}
-      >
-        {c(title)}
-      </div>
-      <hr className="cru-dialog-container-hr" />
-      <div
-        ref={bodyContainerRef}
-        className={classNames(
-          "cru-dialog-container-body",
-          bodyContainerClassName,
-        )}
-      >
-        {children}
-      </div>
-      <hr className="cru-dialog-container-hr" />
-      {"buttons" in props ? (
-        <ButtonRow
-          containerRef={buttonsContainerRef}
-          className={classNames(
-            "cru-dialog-container-button-row",
-            buttonsClassName,
-          )}
-          buttons={props.buttons}
-          buttonsClassName="cru-dialog-container-button"
-        />
-      ) : (
-        <ButtonRowV2
-          containerRef={buttonsContainerRef}
-          className={classNames(
-            "cru-dialog-container-button-row",
-            buttonsClassName,
-          )}
-          buttons={props.buttonsV2}
-          buttonsClassName="cru-dialog-container-button"
-        />
-      )}
-    </div>
-  );
-}
diff --git a/FrontEnd/src/views/common/dialog/FullPageDialog.css b/FrontEnd/src/views/common/dialog/FullPageDialog.css
deleted file mode 100644
index 2f1fc636..00000000
--- a/FrontEnd/src/views/common/dialog/FullPageDialog.css
+++ /dev/null
@@ -1,44 +0,0 @@
-.cru-full-page {
-  position: fixed;
-  z-index: 1030;
-  left: 0;
-  top: 0;
-  right: 0;
-  bottom: 0;
-  background-color: white;
-  padding-top: 56px;
-}
-
-.cru-full-page-top-bar {
-  height: 56px;
-  position: absolute;
-  top: 0;
-  left: 0;
-  right: 0;
-  z-index: 1;
-  background-color: var(--cru-primary-color);
-  display: flex;
-  align-items: center;
-}
-
-.cru-full-page-content-container {
-  overflow: scroll;
-}
-
-.cru-full-page-back-button {
-  color: var(--cru-primary-t-color);
-}
-
-.cru-full-page-enter {
-  transform: translate(100%, 0);
-}
-
-.cru-full-page-enter-active {
-  transform: none;
-  transition: transform 0.3s;
-}
-
-.cru-full-page-exit-active {
-  transition: transform 0.3s;
-  transform: translate(100%, 0);
-}
diff --git a/FrontEnd/src/views/common/dialog/FullPageDialog.tsx b/FrontEnd/src/views/common/dialog/FullPageDialog.tsx
deleted file mode 100644
index 6368fc0a..00000000
--- a/FrontEnd/src/views/common/dialog/FullPageDialog.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-import * as React from "react";
-import { createPortal } from "react-dom";
-import classnames from "classnames";
-import { CSSTransition } from "react-transition-group";
-
-import "./FullPageDialog.css";
-import IconButton from "../button/IconButton";
-
-export interface FullPageDialogProps {
-  show: boolean;
-  onBack: () => void;
-  contentContainerClassName?: string;
-  children: React.ReactNode;
-}
-
-const FullPageDialog: React.FC<FullPageDialogProps> = ({
-  show,
-  onBack,
-  children,
-  contentContainerClassName,
-}) => {
-  return createPortal(
-    <CSSTransition
-      mountOnEnter
-      unmountOnExit
-      in={show}
-      timeout={300}
-      classNames="cru-full-page"
-    >
-      <div className="cru-full-page">
-        <div className="cru-full-page-top-bar">
-          <IconButton
-            icon="arrow-left"
-            className="ms-3 cru-full-page-back-button"
-            onClick={onBack}
-          />
-        </div>
-        <div
-          className={classnames(
-            "cru-full-page-content-container",
-            contentContainerClassName
-          )}
-        >
-          {children}
-        </div>
-      </div>
-    </CSSTransition>,
-    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-    document.getElementById("portal")!
-  );
-};
-
-export default FullPageDialog;
diff --git a/FrontEnd/src/views/common/dialog/OperationDialog.css b/FrontEnd/src/views/common/dialog/OperationDialog.css
deleted file mode 100644
index f4b7237e..00000000
--- a/FrontEnd/src/views/common/dialog/OperationDialog.css
+++ /dev/null
@@ -1,8 +0,0 @@
-.cru-operation-dialog-prompt {
-  color: var(--cru-surface-on-color);
-}
-
-.cru-operation-dialog-input-group {
-  display: block;
-  margin: 0.5em 0;
-}
diff --git a/FrontEnd/src/views/common/dialog/OperationDialog.tsx b/FrontEnd/src/views/common/dialog/OperationDialog.tsx
deleted file mode 100644
index 4335b2b0..00000000
--- a/FrontEnd/src/views/common/dialog/OperationDialog.tsx
+++ /dev/null
@@ -1,228 +0,0 @@
-import { useState, ReactNode, ComponentProps } from "react";
-import classNames from "classnames";
-
-import { useC, Text, ThemeColor } from "../common";
-
-import {
-  useInputs,
-  InputGroup,
-  Initializer as InputInitializer,
-  InputValueDict,
-  InputErrorDict,
-} from "../input/InputGroup";
-import Dialog from "./Dialog";
-import DialogContainer from "./DialogContainer";
-
-import "./OperationDialog.css";
-
-export type { InputInitializer, InputValueDict, InputErrorDict };
-
-interface OperationDialogPromptProps {
-  message?: Text;
-  customMessage?: Text;
-  customMessageNode?: ReactNode;
-  className?: string;
-}
-
-function OperationDialogPrompt(props: OperationDialogPromptProps) {
-  const { message, customMessage, customMessageNode, className } = props;
-
-  const c = useC();
-
-  return (
-    <div className={classNames(className, "cru-operation-dialog-prompt")}>
-      {message && <p>{c(message)}</p>}
-      {customMessageNode ?? (customMessage != null ? c(customMessage) : null)}
-    </div>
-  );
-}
-
-export interface OperationDialogProps<TData> {
-  open: boolean;
-  onClose: () => void;
-
-  color?: ThemeColor;
-  inputColor?: ThemeColor;
-  title: Text;
-  inputPrompt?: Text;
-  inputPromptNode?: ReactNode;
-  successPrompt?: (data: TData) => Text;
-  successPromptNode?: (data: TData) => ReactNode;
-  failurePrompt?: (error: unknown) => Text;
-  failurePromptNode?: (error: unknown) => ReactNode;
-
-  inputs: InputInitializer;
-
-  onProcess: (inputs: InputValueDict) => Promise<TData>;
-  onSuccessAndClose?: (data: TData) => void;
-}
-
-function OperationDialog<TData>(props: OperationDialogProps<TData>) {
-  const {
-    open,
-    onClose,
-    color,
-    inputColor,
-    title,
-    inputPrompt,
-    inputPromptNode,
-    successPrompt,
-    successPromptNode,
-    failurePrompt,
-    failurePromptNode,
-    inputs,
-    onProcess,
-    onSuccessAndClose,
-  } = props;
-
-  if (process.env.NODE_ENV === "development") {
-    if (inputPrompt && inputPromptNode) {
-      console.log("InputPrompt and inputPromptNode are both set.");
-    }
-    if (successPrompt && successPromptNode) {
-      console.log("SuccessPrompt and successPromptNode are both set.");
-    }
-    if (failurePrompt && failurePromptNode) {
-      console.log("FailurePrompt and failurePromptNode are both set.");
-    }
-  }
-
-  type Step =
-    | { type: "input" }
-    | { type: "process" }
-    | {
-        type: "success";
-        data: TData;
-      }
-    | {
-        type: "failure";
-        data: unknown;
-      };
-
-  const [step, setStep] = useState<Step>({ type: "input" });
-
-  const { inputGroupProps, hasErrorAndDirty, setAllDisabled, confirm } =
-    useInputs({
-      init: inputs,
-    });
-
-  function close() {
-    if (step.type !== "process") {
-      onClose();
-      if (step.type === "success" && onSuccessAndClose) {
-        onSuccessAndClose?.(step.data);
-      }
-    } else {
-      console.log("Attempt to close modal dialog when processing.");
-    }
-  }
-
-  function onConfirm() {
-    const result = confirm();
-    if (result.type === "ok") {
-      setStep({ type: "process" });
-      setAllDisabled(true);
-      onProcess(result.values).then(
-        (d) => {
-          setStep({
-            type: "success",
-            data: d,
-          });
-        },
-        (e: unknown) => {
-          setStep({
-            type: "failure",
-            data: e,
-          });
-        },
-      );
-    }
-  }
-
-  let body: ReactNode;
-  let buttons: ComponentProps<typeof DialogContainer>["buttons"];
-
-  if (step.type === "input" || step.type === "process") {
-    const isProcessing = step.type === "process";
-
-    body = (
-      <div>
-        <OperationDialogPrompt
-          customMessage={inputPrompt}
-          customMessageNode={inputPromptNode}
-        />
-        <InputGroup
-          containerClassName="cru-operation-dialog-input-group"
-          color={inputColor ?? "primary"}
-          {...inputGroupProps}
-        />
-      </div>
-    );
-    buttons = [
-      {
-        key: "cancel",
-        type: "normal",
-        props: {
-          text: "operationDialog.cancel",
-          color: "secondary",
-          outline: true,
-          onClick: close,
-          disabled: isProcessing,
-        },
-      },
-      {
-        key: "confirm",
-        type: "loading",
-        props: {
-          text: "operationDialog.confirm",
-          color,
-          loading: isProcessing,
-          disabled: hasErrorAndDirty,
-          onClick: onConfirm,
-        },
-      },
-    ];
-  } else {
-    const result = step;
-
-    const promptProps: OperationDialogPromptProps =
-      result.type === "success"
-        ? {
-            message: "operationDialog.success",
-            customMessage: successPrompt?.(result.data),
-            customMessageNode: successPromptNode?.(result.data),
-          }
-        : {
-            message: "operationDialog.error",
-            customMessage: failurePrompt?.(result.data),
-            customMessageNode: failurePromptNode?.(result.data),
-          };
-    body = (
-      <div>
-        <OperationDialogPrompt {...promptProps} />
-      </div>
-    );
-
-    buttons = [
-      {
-        key: "ok",
-        type: "normal",
-        props: {
-          text: "operationDialog.ok",
-          color: "primary",
-          onClick: close,
-        },
-      },
-    ];
-  }
-
-  return (
-    <Dialog open={open} onClose={close}>
-      <DialogContainer title={title} titleColor={color} buttons={buttons}>
-        {body}
-      </DialogContainer>
-    </Dialog>
-  );
-}
-
-export default OperationDialog;
diff --git a/FrontEnd/src/views/common/dialog/index.ts b/FrontEnd/src/views/common/dialog/index.ts
deleted file mode 100644
index 59f15791..00000000
--- a/FrontEnd/src/views/common/dialog/index.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-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[],
-  options?: {
-    initDialog?: D | null;
-    onClose?: {
-      [K in D]?: () => void;
-    };
-  },
-): {
-  dialog: D | null;
-  switchDialog: (newDialog: D | null) => void;
-  dialogPropsMap: DialogPropsMap<D>;
-  createDialogSwitch: (newDialog: D | null) => () => void;
-} {
-  const [dialog, setDialog] = useState<D | null>(options?.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);
-            options?.onClose?.[d]?.();
-          },
-        },
-      ]),
-    ) as DialogPropsMap<D>,
-    createDialogSwitch: (newDialog: D | null) => () => switchDialog(newDialog),
-  };
-}
diff --git a/FrontEnd/src/views/common/hooks.ts b/FrontEnd/src/views/common/hooks.ts
deleted file mode 100644
index c1fa5774..00000000
--- a/FrontEnd/src/views/common/hooks.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-// TODO: Migrate hooks
-
-export {
-  useIsSmallScreen,
-  useClickOutside,
-  useScrollToBottom,
-} from "@/utilities/hooks";
-
-import { useMediaQuery } from "react-responsive";
-import { breakpoints } from "./breakpoints";
-
-export function useMobile(): boolean {
-  return useMediaQuery({ maxWidth: breakpoints.sm });
-}
diff --git a/FrontEnd/src/views/common/index.css b/FrontEnd/src/views/common/index.css
deleted file mode 100644
index a8f5e9a5..00000000
--- a/FrontEnd/src/views/common/index.css
+++ /dev/null
@@ -1,100 +0,0 @@
-@import "./theme.css";
-
-* {
-  box-sizing: border-box;
-  margin-inline: 0;
-  margin-block: 0;
-}
-
-body {
-  font-family: var(--cru-default-font-family);
-  background: var(--cru-body-background-color);
-  color: var(--cru-text-primary-color);
-  line-height: 1.2;
-}
-
-.cru-page {
-  padding: var(--cru-page-padding);
-}
-
-.cru-page-no-top-padding {
-  padding-top: 0;
-}
-
-.cru-text-center {
-  text-align: center;
-}
-
-.cru-text-end {
-  text-align: end;
-}
-
-.cru-float-left {
-  float: left;
-}
-
-.cru-float-right {
-  float: right;
-}
-
-.cru-align-text-bottom {
-  vertical-align: text-bottom;
-}
-
-.cru-align-middle {
-  vertical-align: middle;
-}
-
-.cru-clearfix::after {
-  clear: both;
-}
-
-.cru-fill-parent {
-  width: 100%;
-  height: 100%;
-}
-
-.cru-avatar {
-  width: 60px;
-  height: 60px;
-}
-
-.cru-avatar.large {
-  width: 100px;
-  height: 100px;
-}
-
-.cru-avatar.small {
-  width: 40px;
-  height: 40px;
-}
-
-.cru-round {
-  border-radius: 50%;
-}
-
-.cru-tab-pages-action-area {
-  display: flex;
-  align-items: center;
-}
-
-.alert-container {
-  position: fixed;
-  z-index: 1070;
-}
-
-@media (min-width: 576px) {
-  .alert-container {
-    bottom: 0;
-    right: 0;
-  }
-}
-
-@media (max-width: 575.98px) {
-  .alert-container {
-    bottom: 0;
-    right: 0;
-    left: 0;
-    text-align: center;
-  }
-}
\ No newline at end of file
diff --git a/FrontEnd/src/views/common/input/InputGroup.css b/FrontEnd/src/views/common/input/InputGroup.css
deleted file mode 100644
index 7e905b1e..00000000
--- a/FrontEnd/src/views/common/input/InputGroup.css
+++ /dev/null
@@ -1,54 +0,0 @@
-.cru-input-group {
-  display: block;
-}
-
-.cru-input-container {
-  margin: 0.4em 0;
-}
-
-.cru-input-label {
-  display: block;
-  color: var(--cru-clickable-normal-color);
-  font-size: 0.9em;
-  margin-bottom: 0.3em;
-}
-
-.cru-input-label-inline {
-  margin-inline-start: 0.5em;
-}
-
-.cru-input-type-text input {
-  appearance: none;
-  display: block;
-  border: 1px solid;
-  /* color: var(--cru-surface-on-color); */
-  /* background-color: var(--cru-surface-color); */
-  margin: 0;
-  font-size: 1em;
-  padding: 0.2em;
-}
-
-.cru-input-type-text input:hover {
-  border-color: var(--cru-clickable-hover-color);
-}
-
-.cru-input-type-text input:focus {
-  border-color: var(--cru-clickable-focus-color);
-}
-
-.cru-input-type-text input:disabled {
-  border-color: var(--cru-clickable-disabled-color);
-}
-
-.cru-input-error {
-  display: block;
-  font-size: 0.8em;
-  color: var(--cru-danger-color);
-  margin-top: 0.4em;
-}
-
-.cru-input-helper {
-  display: block;
-  font-size: 0.8em;
-  color: var(--cru-primary-color);
-}
\ No newline at end of file
diff --git a/FrontEnd/src/views/common/input/InputGroup.tsx b/FrontEnd/src/views/common/input/InputGroup.tsx
deleted file mode 100644
index d95bb29e..00000000
--- a/FrontEnd/src/views/common/input/InputGroup.tsx
+++ /dev/null
@@ -1,461 +0,0 @@
-/**
- * Some notes for InputGroup:
- * This is one of the most complicated components in this project.
- * Probably because the feature is complex and involved user inputs.
- *
- * I hope it contains following features:
- * - Input features
- *   - Supports a wide range of input types.
- *   - Validator to validate user inputs.
- *   - Can set initial values.
- *   - Dirty, aka, has user touched this input.
- * - Developer friendly
- *   - Easy to use APIs.
- *   - Type check as much as possible.
- * - UI
- *   - Configurable appearance.
- *   - Can display helper and error messages.
- * - Easy to extend, like new input types.
- *
- * So here is some design decisions:
- * Inputs are identified by its _key_.
- * `InputGroup` component takes care of only UI and no logic.
- * `useInputs` hook takes care of logic and generate props for `InputGroup`.
- */
-
-import { useState, Ref, useId } from "react";
-import classNames from "classnames";
-
-import { useC, Text, ThemeColor } from "../common";
-
-import "./InputGroup.css";
-
-export interface InputBase {
-  key: string;
-  label: Text;
-  helper?: Text;
-  disabled?: boolean;
-  error?: Text;
-}
-
-export interface TextInput extends InputBase {
-  type: "text";
-  value: string;
-  password?: boolean;
-}
-
-export interface BoolInput extends InputBase {
-  type: "bool";
-  value: boolean;
-}
-
-export interface SelectInputOption {
-  value: string;
-  label: Text;
-  icon?: string;
-}
-
-export interface SelectInput extends InputBase {
-  type: "select";
-  value: string;
-  options: SelectInputOption[];
-}
-
-export type Input = TextInput | BoolInput | SelectInput;
-
-export type InputValue = Input["value"];
-
-export type InputValueDict = Record<string, InputValue>;
-export type InputErrorDict = Record<string, Text>;
-export type InputDisabledDict = Record<string, boolean>;
-export type InputDirtyDict = Record<string, boolean>;
-
-export type GeneralInputErrorDict =
-  | {
-      [key: string]: Text | null | undefined;
-    }
-  | null
-  | undefined;
-
-type MakeInputInfo<I extends Input> = Omit<I, "value" | "error" | "disabled">;
-
-export type InputInfo = {
-  [I in Input as I["type"]]: MakeInputInfo<I>;
-}[Input["type"]];
-
-export type Validator = (
-  values: InputValueDict,
-  inputs: InputInfo[],
-) => GeneralInputErrorDict;
-
-export type InputScheme = {
-  inputs: InputInfo[];
-  validator?: Validator;
-};
-
-export type InputData = {
-  values: InputValueDict;
-  errors: InputErrorDict;
-  disabled: InputDisabledDict;
-  dirties: InputDirtyDict;
-};
-
-export type State = {
-  scheme: InputScheme;
-  data: InputData;
-};
-
-export type DataInitialization = {
-  values?: InputValueDict;
-  errors?: GeneralInputErrorDict;
-  disabled?: InputDisabledDict;
-  dirties?: InputDirtyDict;
-};
-
-export type Initialization = {
-  scheme: InputScheme;
-  dataInit?: DataInitialization;
-};
-
-export type GeneralInitialization = Initialization | InputScheme | InputInfo[];
-
-export type Initializer = GeneralInitialization | (() => GeneralInitialization);
-
-export interface InputGroupProps {
-  color?: ThemeColor;
-  containerClassName?: string;
-  containerRef?: Ref<HTMLDivElement>;
-
-  inputs: Input[];
-  onChange: (index: number, value: Input["value"]) => void;
-}
-
-function cleanObject<V>(o: Record<string, V>): Record<string, NonNullable<V>> {
-  const result = { ...o };
-  for (const key of Object.keys(result)) {
-    if (result[key] == null) {
-      delete result[key];
-    }
-  }
-  return result as never;
-}
-
-export type ConfirmResult =
-  | {
-      type: "ok";
-      values: InputValueDict;
-    }
-  | {
-      type: "error";
-      errors: InputErrorDict;
-    };
-
-function validate(
-  validator: Validator | null | undefined,
-  values: InputValueDict,
-  inputs: InputInfo[],
-): InputErrorDict {
-  return cleanObject(validator?.(values, inputs) ?? {});
-}
-
-export function useInputs(options: { init: Initializer }): {
-  inputGroupProps: InputGroupProps;
-  hasError: boolean;
-  hasErrorAndDirty: boolean;
-  confirm: () => ConfirmResult;
-  setAllDisabled: (disabled: boolean) => void;
-} {
-  function initializeValue(
-    input: InputInfo,
-    value?: InputValue | null,
-  ): InputValue {
-    if (input.type === "text") {
-      return value ?? "";
-    } else if (input.type === "bool") {
-      return value ?? false;
-    } else if (input.type === "select") {
-      return value ?? input.options[0].value;
-    }
-    throw new Error("Unknown input type");
-  }
-
-  function initialize(generalInitialization: GeneralInitialization): State {
-    const initialization: Initialization = Array.isArray(generalInitialization)
-      ? { scheme: { inputs: generalInitialization } }
-      : "scheme" in generalInitialization
-      ? generalInitialization
-      : { scheme: generalInitialization };
-
-    const { scheme, dataInit } = initialization;
-    const { inputs, validator } = scheme;
-    const keys = inputs.map((input) => input.key);
-
-    if (process.env.NODE_ENV === "development") {
-      const checkKeys = (dict: Record<string, unknown> | undefined) => {
-        if (dict != null) {
-          for (const key of Object.keys(dict)) {
-            if (!keys.includes(key)) {
-              console.warn("");
-            }
-          }
-        }
-      };
-
-      checkKeys(dataInit?.values);
-      checkKeys(dataInit?.errors ?? {});
-      checkKeys(dataInit?.disabled);
-      checkKeys(dataInit?.dirties);
-    }
-
-    function clean<V>(
-      dict: Record<string, V> | null | undefined,
-    ): Record<string, NonNullable<V>> {
-      return dict != null ? cleanObject(dict) : {};
-    }
-
-    const values: InputValueDict = {};
-    const disabled: InputDisabledDict = clean(dataInit?.disabled);
-    const dirties: InputDirtyDict = clean(dataInit?.dirties);
-    const isErrorSet = dataInit?.errors != null;
-    let errors: InputErrorDict = clean(dataInit?.errors);
-
-    for (let i = 0; i < inputs.length; i++) {
-      const input = inputs[i];
-      const { key } = input;
-
-      values[key] = initializeValue(input, dataInit?.values?.[key]);
-    }
-
-    if (isErrorSet) {
-      if (process.env.NODE_ENV === "development") {
-        console.log(
-          "You explicitly set errors (not undefined) in initializer, so validator won't run.",
-        );
-      }
-    } else {
-      errors = validate(validator, values, inputs);
-    }
-
-    return {
-      scheme,
-      data: {
-        values,
-        errors,
-        disabled,
-        dirties,
-      },
-    };
-  }
-
-  const { init } = options;
-  const initializer = typeof init === "function" ? init : () => init;
-
-  const [state, setState] = useState<State>(() => initialize(initializer()));
-
-  const { scheme, data } = state;
-  const { validator } = scheme;
-
-  function createAllBooleanDict(value: boolean): Record<string, boolean> {
-    const result: InputDirtyDict = {};
-    for (const key of scheme.inputs.map((input) => input.key)) {
-      result[key] = value;
-    }
-    return result;
-  }
-
-  const createAllDirties = () => createAllBooleanDict(true);
-
-  const componentInputs: Input[] = [];
-
-  for (let i = 0; i < scheme.inputs.length; i++) {
-    const input = scheme.inputs[i];
-    const value = data.values[input.key];
-    const error = data.errors[input.key];
-    const disabled = data.disabled[input.key] ?? false;
-    const dirty = data.dirties[input.key] ?? false;
-    const componentInput: Input = {
-      ...input,
-      value: value as never,
-      disabled,
-      error: dirty ? error : undefined,
-    };
-    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,
-      onChange: (index, value) => {
-        const input = scheme.inputs[index];
-        const { key } = input;
-        const newValues = { ...data.values, [key]: value };
-        const newDirties = { ...data.dirties, [key]: true };
-        const newErrors = validate(validator, newValues, scheme.inputs);
-        setState({
-          scheme,
-          data: {
-            ...data,
-            values: newValues,
-            errors: newErrors,
-            dirties: newDirties,
-          },
-        });
-      },
-    },
-    hasError,
-    hasErrorAndDirty: hasError && hasDirty,
-    confirm() {
-      const newDirties = createAllDirties();
-      const newErrors = validate(validator, data.values, scheme.inputs);
-
-      setState({
-        scheme,
-        data: {
-          ...data,
-          dirties: newDirties,
-          errors: newErrors,
-        },
-      });
-
-      if (Object.keys(newErrors).length !== 0) {
-        return {
-          type: "error",
-          errors: newErrors,
-        };
-      } else {
-        return {
-          type: "ok",
-          values: data.values,
-        };
-      }
-    },
-    setAllDisabled(disabled: boolean) {
-      setState({
-        scheme,
-        data: {
-          ...data,
-          disabled: createAllBooleanDict(disabled),
-        },
-      });
-    },
-  };
-}
-
-export function InputGroup({
-  color,
-  inputs,
-  onChange,
-  containerRef,
-  containerClassName,
-}: InputGroupProps) {
-  const c = useC();
-
-  const id = useId();
-
-  return (
-    <div
-      ref={containerRef}
-      className={classNames(
-        "cru-input-group",
-        `cru-clickable-${color ?? "primary"}`,
-        containerClassName,
-      )}
-    >
-      {inputs.map((item, index) => {
-        const { key, type, value, label, error, helper, disabled } = item;
-
-        const getContainerClassName = (
-          ...additionalClassNames: classNames.ArgumentArray
-        ) =>
-          classNames(
-            `cru-input-container cru-input-type-${type}`,
-            error && "error",
-            ...additionalClassNames,
-          );
-
-        const changeValue = (value: InputValue) => {
-          onChange(index, value);
-        };
-
-        const inputId = `${id}-${key}`;
-
-        if (type === "text") {
-          const { password } = item;
-          return (
-            <div
-              key={key}
-              className={getContainerClassName(password && "password")}
-            >
-              {label && (
-                <label className="cru-input-label" htmlFor={inputId}>
-                  {c(label)}
-                </label>
-              )}
-              <input
-                id={inputId}
-                type={password ? "password" : "text"}
-                value={value}
-                onChange={(event) => {
-                  const v = event.target.value;
-                  changeValue(v);
-                }}
-                disabled={disabled}
-              />
-              {error && <div className="cru-input-error">{c(error)}</div>}
-              {helper && <div className="cru-input-helper">{c(helper)}</div>}
-            </div>
-          );
-        } else if (type === "bool") {
-          return (
-            <div key={key} className={getContainerClassName()}>
-              <input
-                id={inputId}
-                type="checkbox"
-                checked={value}
-                onChange={(event) => {
-                  const v = event.currentTarget.checked;
-                  changeValue(v);
-                }}
-                disabled={disabled}
-              />
-              <label className="cru-input-label-inline" htmlFor={inputId}>
-                {c(label)}
-              </label>
-              {error && <div className="cru-input-error">{c(error)}</div>}
-              {helper && <div className="cru-input-helper">{c(helper)}</div>}
-            </div>
-          );
-        } else if (type === "select") {
-          return (
-            <div key={key} className={getContainerClassName()}>
-              <label className="cru-input-label" htmlFor={inputId}>
-                {c(label)}
-              </label>
-              <select
-                id={inputId}
-                value={value}
-                onChange={(event) => {
-                  const e = event.target.value;
-                  changeValue(e);
-                }}
-                disabled={disabled}
-              >
-                {item.options.map((option) => {
-                  return (
-                    <option value={option.value} key={option.value}>
-                      {option.icon}
-                      {c(option.label)}
-                    </option>
-                  );
-                })}
-              </select>
-            </div>
-          );
-        }
-      })}
-    </div>
-  );
-}
diff --git a/FrontEnd/src/views/common/list/ListContainer.css b/FrontEnd/src/views/common/list/ListContainer.css
deleted file mode 100644
index 53781834..00000000
--- a/FrontEnd/src/views/common/list/ListContainer.css
+++ /dev/null
@@ -1,4 +0,0 @@
-.cru-list-container {
-  border: 1px solid var(--cru-clickable-primary-normal-color);
-  border-radius: 5px;
-}
diff --git a/FrontEnd/src/views/common/list/ListContainer.tsx b/FrontEnd/src/views/common/list/ListContainer.tsx
deleted file mode 100644
index aa00d12c..00000000
--- a/FrontEnd/src/views/common/list/ListContainer.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-import { ComponentPropsWithoutRef, forwardRef, Ref } from "react";
-import classNames from "classnames";
-
-import "./ListContainer.css"
-
-function _ListContainer(
-  { className, children, ...otherProps }: ComponentPropsWithoutRef<"div">,
-  ref: Ref<HTMLDivElement>,
-) {
-  return (
-    <div
-      ref={ref}
-      className={classNames("cru-list-container", className)}
-      {...otherProps}
-    >
-      {children}
-    </div>
-  );
-}
-
-const ListContainer = forwardRef(_ListContainer);
-
-export default ListContainer;
diff --git a/FrontEnd/src/views/common/list/ListItemContainer.css b/FrontEnd/src/views/common/list/ListItemContainer.css
deleted file mode 100644
index 8d7afa9f..00000000
--- a/FrontEnd/src/views/common/list/ListItemContainer.css
+++ /dev/null
@@ -1,3 +0,0 @@
-.cru-list-item-container {
-  border: 1px solid var(--cru-clickable-primary-normal-color);
-}
diff --git a/FrontEnd/src/views/common/list/ListItemContainer.tsx b/FrontEnd/src/views/common/list/ListItemContainer.tsx
deleted file mode 100644
index 315cbd6e..00000000
--- a/FrontEnd/src/views/common/list/ListItemContainer.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-import { ComponentPropsWithoutRef, forwardRef, Ref } from "react";
-import classNames from "classnames";
-
-import "./ListItemContainer.css";
-
-function _ListItemContainer(
-  { className, children, ...otherProps }: ComponentPropsWithoutRef<"div">,
-  ref: Ref<HTMLDivElement>,
-) {
-  return (
-    <div
-      ref={ref}
-      className={classNames("cru-list-item-container", className)}
-      {...otherProps}
-    >
-      {children}
-    </div>
-  );
-}
-
-const ListItemContainer = forwardRef(_ListItemContainer);
-
-export default ListItemContainer;
diff --git a/FrontEnd/src/views/common/list/index.ts b/FrontEnd/src/views/common/list/index.ts
deleted file mode 100644
index e183f7da..00000000
--- a/FrontEnd/src/views/common/list/index.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-import ListContainer from "./ListContainer";
-import ListItemContainer from "./ListItemContainer";
-
-export { ListContainer, ListItemContainer };
diff --git a/FrontEnd/src/views/common/menu/Menu.css b/FrontEnd/src/views/common/menu/Menu.css
deleted file mode 100644
index 75734533..00000000
--- a/FrontEnd/src/views/common/menu/Menu.css
+++ /dev/null
@@ -1,36 +0,0 @@
-.cru-menu {
-  min-width: 200px;
-}
-
-.cru-menu-item {
-  display: block;
-  font-size: 1em;
-  width: 100%;
-  padding: 0.5em 1.5em;
-  transition: all 0.5s;
-  color: var(--cru-clickable-normal-color);
-  background-color: var(--cru-clickable-grayscale-normal-color);
-  border: none;
-  cursor: pointer;
-}
-
-.cru-menu-item:hover {
-  background-color: var(--cru-clickable-grayscale-hover-color);
-}
-
-.cru-menu-item:focus {
-  background-color: var(--cru-clickable-grayscale-focus-color);
-}
-
-.cru-menu-item:active {
-  background-color: var(--cru-clickable-grayscale-active-color);
-}
-
-.cru-menu-item-icon {
-  margin-right: 1em;
-}
-
-.cru-menu-divider {
-  border-width: 0;
-  border-top: 1px solid var(--cru-primary-color);
-}
\ No newline at end of file
diff --git a/FrontEnd/src/views/common/menu/Menu.tsx b/FrontEnd/src/views/common/menu/Menu.tsx
deleted file mode 100644
index e8099c76..00000000
--- a/FrontEnd/src/views/common/menu/Menu.tsx
+++ /dev/null
@@ -1,62 +0,0 @@
-import { CSSProperties } from "react";
-import classNames from "classnames";
-
-import { useC, Text, ThemeColor } from "../common";
-
-import "./Menu.css";
-import Icon from "../Icon";
-
-export type MenuItem =
-  | {
-      type: "divider";
-    }
-  | {
-      type: "button";
-      text: Text;
-      icon?: string;
-      color?: ThemeColor;
-      onClick: () => void;
-    };
-
-export type MenuItems = MenuItem[];
-
-export type MenuProps = {
-  items: MenuItems;
-  onItemClicked?: () => void;
-  className?: string;
-  style?: CSSProperties;
-};
-
-export default function Menu({
-  items,
-  onItemClicked,
-  className,
-  style,
-}: MenuProps) {
-  const c = useC();
-
-  return (
-    <div className={classNames("cru-menu", className)} style={style}>
-      {items.map((item, index) => {
-        if (item.type === "divider") {
-          return <hr key={index} className="cru-menu-divider" />;
-        } else {
-          const { text, color, icon, onClick } = item;
-          return (
-            <button
-              key={index}
-              className={`cru-menu-item cru-clickable-${color ?? "primary"}`}
-              onClick={() => {
-                onClick();
-                onItemClicked?.();
-              }}
-            >
-              {icon != null && <Icon color={color} icon={icon} />}
-              {c(text)}
-            </button>
-          );
-        }
-      })}
-    </div>
-  );
-}
diff --git a/FrontEnd/src/views/common/menu/PopupMenu.css b/FrontEnd/src/views/common/menu/PopupMenu.css
deleted file mode 100644
index 149e0699..00000000
--- a/FrontEnd/src/views/common/menu/PopupMenu.css
+++ /dev/null
@@ -1,7 +0,0 @@
-.cru-popup-menu-menu-container {
-  z-index: 1040;
-  border-radius: 3px;
-  border: var(--cru-clickable-normal-color) 1.5px solid;
-  background-color: var(--cru-background-color);
-  overflow: hidden;
-}
diff --git a/FrontEnd/src/views/common/menu/PopupMenu.tsx b/FrontEnd/src/views/common/menu/PopupMenu.tsx
deleted file mode 100644
index 5c8d5e98..00000000
--- a/FrontEnd/src/views/common/menu/PopupMenu.tsx
+++ /dev/null
@@ -1,73 +0,0 @@
-import { useState, CSSProperties, ReactNode } from "react";
-import classNames from "classnames";
-import { createPortal } from "react-dom";
-import { usePopper } from "react-popper";
-
-import { useClickOutside } from "@/utilities/hooks";
-
-import Menu, { MenuItems } from "./Menu";
-
-import { ThemeColor } from "../common";
-
-import "./PopupMenu.css";
-
-export interface PopupMenuProps {
-  color?: ThemeColor;
-  items: MenuItems;
-  children?: ReactNode;
-  containerClassName?: string;
-  containerStyle?: CSSProperties;
-}
-
-export default function PopupMenu({
-  color,
-  items,
-  children,
-  containerClassName,
-  containerStyle,
-}: PopupMenuProps) {
-  const [show, setShow] = useState<boolean>(false);
-
-  const [referenceElement, setReferenceElement] =
-    useState<HTMLDivElement | null>(null);
-  const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(
-    null,
-  );
-  const { styles, attributes } = usePopper(referenceElement, popperElement);
-
-  useClickOutside(popperElement, () => setShow(false), true);
-
-  return (
-    <div
-      ref={setReferenceElement}
-      className={classNames(
-        "cru-popup-menu-trigger-container",
-        containerClassName,
-      )}
-      style={containerStyle}
-      onClick={() => setShow(true)}
-    >
-      {children}
-      {show &&
-        createPortal(
-          <div
-            ref={setPopperElement}
-            className={`cru-popup-menu-menu-container cru-clickable-${
-              color ?? "primary"
-            }`}
-            style={styles.popper}
-            {...attributes.popper}
-          >
-            <Menu
-              items={items}
-              onItemClicked={() => {
-                setShow(false);
-              }}
-            />
-          </div>,
-          // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-          document.getElementById("portal")!,
-        )}
-    </div>
-  );
-}
diff --git a/FrontEnd/src/views/common/tab/TabPages.tsx b/FrontEnd/src/views/common/tab/TabPages.tsx
deleted file mode 100644
index cdb988e0..00000000
--- a/FrontEnd/src/views/common/tab/TabPages.tsx
+++ /dev/null
@@ -1,71 +0,0 @@
-import * as React from "react";
-
-import { I18nText, UiLogicError } from "@/common";
-
-import Tabs from "./Tabs";
-
-export interface TabPage {
-  name: string;
-  text: I18nText;
-  page: React.ReactNode;
-}
-
-export interface TabPagesProps {
-  pages: TabPage[];
-  actions?: React.ReactNode;
-  dense?: boolean;
-  className?: string;
-  style?: React.CSSProperties;
-  navClassName?: string;
-  navStyle?: React.CSSProperties;
-  pageContainerClassName?: string;
-  pageContainerStyle?: React.CSSProperties;
-}
-
-const TabPages: React.FC<TabPagesProps> = ({
-  pages,
-  actions,
-  dense,
-  className,
-  style,
-  navClassName,
-  navStyle,
-  pageContainerClassName,
-  pageContainerStyle,
-}) => {
-  if (pages.length === 0) {
-    throw new UiLogicError("Page list can't be empty.");
-  }
-
-  const [tab, setTab] = React.useState<string>(pages[0].name);
-
-  const currentPage = pages.find((p) => p.name === tab);
-
-  if (currentPage == null) {
-    throw new UiLogicError("Current tab value is bad.");
-  }
-
-  return (
-    <div className={className} style={style}>
-      <Tabs
-        tabs={pages.map((page) => ({
-          name: page.name,
-          text: page.text,
-          onClick: () => {
-            setTab(page.name);
-          },
-        }))}
-        dense={dense}
-        activeTabName={tab}
-        className={navClassName}
-        style={navStyle}
-        actions={actions}
-      />
-      <div className={pageContainerClassName} style={pageContainerStyle}>
-        {currentPage.page}
-      </div>
-    </div>
-  );
-};
-
-export default TabPages;
diff --git a/FrontEnd/src/views/common/tab/Tabs.css b/FrontEnd/src/views/common/tab/Tabs.css
deleted file mode 100644
index 395d16a7..00000000
--- a/FrontEnd/src/views/common/tab/Tabs.css
+++ /dev/null
@@ -1,33 +0,0 @@
-.cru-nav {
-  border-bottom: var(--cru-primary-color) 1px solid;
-  display: flex;
-}
-
-.cru-nav-item {
-  color: var(--cru-primary-color);
-  border: var(--cru-background-2-color) 0.5px solid;
-  border-bottom: none;
-  padding: 0.5em 1.5em;
-  border-top-left-radius: 5px;
-  border-top-right-radius: 5px;
-  transition: all 0.5s;
-  cursor: pointer;
-}
-
-.cru-nav.dense .cru-nav-item {
-  padding: 0.2em 1em;
-}
-
-.cru-nav-item:hover {
-  background-color: var(--cru-background-1-color);
-}
-
-.cru-nav-item.active {
-  color: var(--cru-primary-t-color);
-  background-color: var(--cru-primary-color);
-  border-color: var(--cru-primary-color);
-}
-
-.cru-nav-action-area {
-  margin-left: auto;
-}
diff --git a/FrontEnd/src/views/common/tab/Tabs.tsx b/FrontEnd/src/views/common/tab/Tabs.tsx
deleted file mode 100644
index 3e3ef6fa..00000000
--- a/FrontEnd/src/views/common/tab/Tabs.tsx
+++ /dev/null
@@ -1,62 +0,0 @@
-import * as React from "react";
-import { Link } from "react-router-dom";
-import { useTranslation } from "react-i18next";
-import classnames from "classnames";
-
-import { convertI18nText, I18nText } from "@/common";
-
-import "./Tabs.css";
-
-export interface Tab {
-  name: string;
-  text: I18nText;
-  link?: string;
-  onClick?: () => void;
-}
-
-export interface TabsProps {
-  activeTabName?: string;
-  actions?: React.ReactNode;
-  dense?: boolean;
-  tabs: Tab[];
-  className?: string;
-  style?: React.CSSProperties;
-}
-
-export default function Tabs(props: TabsProps): React.ReactElement | null {
-  const { tabs, activeTabName, className, style, dense, actions } = props;
-
-  const { t } = useTranslation();
-
-  return (
-    <div
-      className={classnames("cru-nav", dense && "dense", className)}
-      style={style}
-    >
-      {tabs.map((tab) => {
-        const active = activeTabName === tab.name;
-        const className = classnames("cru-nav-item", active && "active");
-
-        if (tab.link != null) {
-          return (
-            <Link
-              key={tab.name}
-              to={tab.link}
-              onClick={tab.onClick}
-              className={className}
-            >
-              {convertI18nText(tab.text, t)}
-            </Link>
-          );
-        } else {
-          return (
-            <span key={tab.name} onClick={tab.onClick} className={className}>
-              {convertI18nText(tab.text, t)}
-            </span>
-          );
-        }
-      })}
-      <div className="cru-nav-action-area">{actions}</div>
-    </div>
-  );
-}
diff --git a/FrontEnd/src/views/common/theme-color.css b/FrontEnd/src/views/common/theme-color.css
deleted file mode 100644
index 24a7e267..00000000
--- a/FrontEnd/src/views/common/theme-color.css
+++ /dev/null
@@ -1,173 +0,0 @@
-/* Generated by theme-generator.ts */
-
-:root {
-  --cru-primary-color: hsl(210 100% 40%);
-  --cru-primary-1-color: hsl(210 100% 37%);
-  --cru-primary-2-color: hsl(210 100% 34%);
-  --cru-primary-on-color: hsl(210 100% 100%);
-  --cru-primary-container-color: hsl(210 100% 90%);
-  --cru-primary-container-1-color: hsl(210 100% 80%);
-  --cru-primary-container-2-color: hsl(210 100% 70%);
-  --cru-primary-on-container-color: hsl(210 100% 10%);
-  --cru-secondary-color: hsl(40 100% 40%);
-  --cru-secondary-1-color: hsl(40 100% 37%);
-  --cru-secondary-2-color: hsl(40 100% 34%);
-  --cru-secondary-on-color: hsl(40 100% 100%);
-  --cru-secondary-container-color: hsl(40 100% 90%);
-  --cru-secondary-container-1-color: hsl(40 100% 80%);
-  --cru-secondary-container-2-color: hsl(40 100% 70%);
-  --cru-secondary-on-container-color: hsl(40 100% 10%);
-  --cru-tertiary-color: hsl(160 100% 40%);
-  --cru-tertiary-1-color: hsl(160 100% 37%);
-  --cru-tertiary-2-color: hsl(160 100% 34%);
-  --cru-tertiary-on-color: hsl(160 100% 100%);
-  --cru-tertiary-container-color: hsl(160 100% 90%);
-  --cru-tertiary-container-1-color: hsl(160 100% 80%);
-  --cru-tertiary-container-2-color: hsl(160 100% 70%);
-  --cru-tertiary-on-container-color: hsl(160 100% 10%);
-  --cru-danger-color: hsl(0 100% 40%);
-  --cru-danger-1-color: hsl(0 100% 37%);
-  --cru-danger-2-color: hsl(0 100% 34%);
-  --cru-danger-on-color: hsl(0 100% 100%);
-  --cru-danger-container-color: hsl(0 100% 90%);
-  --cru-danger-container-1-color: hsl(0 100% 80%);
-  --cru-danger-container-2-color: hsl(0 100% 70%);
-  --cru-danger-on-container-color: hsl(0 100% 10%);
-  --cru-success-color: hsl(120 60% 40%);
-  --cru-success-1-color: hsl(120 60% 37%);
-  --cru-success-2-color: hsl(120 60% 34%);
-  --cru-success-on-color: hsl(120 60% 100%);
-  --cru-success-container-color: hsl(120 60% 90%);
-  --cru-success-container-1-color: hsl(120 60% 80%);
-  --cru-success-container-2-color: hsl(120 60% 70%);
-  --cru-success-on-container-color: hsl(120 60% 10%);
-  --cru-surface-dim-color: hsl(0 0% 87%);
-  --cru-surface-color: hsl(0 0% 98%);
-  --cru-surface-1-color: hsl(0 0% 90%);
-  --cru-surface-2-color: hsl(0 0% 82%);
-  --cru-surface-bright-color: hsl(0 0% 98%);
-  --cru-surface-container-lowest-color: hsl(0 0% 100%);
-  --cru-surface-container-low-color: hsl(0 0% 96%);
-  --cru-surface-container-color: hsl(0 0% 94%);
-  --cru-surface-container-high-color: hsl(0 0% 92%);
-  --cru-surface-container-highest-color: hsl(0 0% 90%);
-  --cru-surface-on-color: hsl(0 0% 10%);
-  --cru-surface-on-variant-color: hsl(0 0% 30%);
-  --cru-surface-outline-color: hsl(0 0% 50%);
-  --cru-surface-outline-variant-color: hsl(0 0% 80%);
-}
-
-@media (prefers-color-scheme: dark) {
-  :root {
-    --cru-primary-color: hsl(210 100% 80%);
-    --cru-primary-1-color: hsl(210 100% 75%);
-    --cru-primary-2-color: hsl(210 100% 68%);
-    --cru-primary-on-color: hsl(210 100% 20%);
-    --cru-primary-container-color: hsl(210 100% 30%);
-    --cru-primary-container-1-color: hsl(210 100% 25%);
-    --cru-primary-container-2-color: hsl(210 100% 20%);
-    --cru-primary-on-container-color: hsl(210 100% 90%);
-    --cru-secondary-color: hsl(40 100% 80%);
-    --cru-secondary-1-color: hsl(40 100% 75%);
-    --cru-secondary-2-color: hsl(40 100% 68%);
-    --cru-secondary-on-color: hsl(40 100% 20%);
-    --cru-secondary-container-color: hsl(40 100% 30%);
-    --cru-secondary-container-1-color: hsl(40 100% 25%);
-    --cru-secondary-container-2-color: hsl(40 100% 20%);
-    --cru-secondary-on-container-color: hsl(40 100% 90%);
-    --cru-tertiary-color: hsl(160 100% 80%);
-    --cru-tertiary-1-color: hsl(160 100% 75%);
-    --cru-tertiary-2-color: hsl(160 100% 68%);
-    --cru-tertiary-on-color: hsl(160 100% 20%);
-    --cru-tertiary-container-color: hsl(160 100% 30%);
-    --cru-tertiary-container-1-color: hsl(160 100% 25%);
-    --cru-tertiary-container-2-color: hsl(160 100% 20%);
-    --cru-tertiary-on-container-color: hsl(160 100% 90%);
-    --cru-danger-color: hsl(0 100% 80%);
-    --cru-danger-1-color: hsl(0 100% 75%);
-    --cru-danger-2-color: hsl(0 100% 68%);
-    --cru-danger-on-color: hsl(0 100% 20%);
-    --cru-danger-container-color: hsl(0 100% 30%);
-    --cru-danger-container-1-color: hsl(0 100% 25%);
-    --cru-danger-container-2-color: hsl(0 100% 20%);
-    --cru-danger-on-container-color: hsl(0 100% 90%);
-    --cru-success-color: hsl(120 60% 80%);
-    --cru-success-1-color: hsl(120 60% 75%);
-    --cru-success-2-color: hsl(120 60% 68%);
-    --cru-success-on-color: hsl(120 60% 20%);
-    --cru-success-container-color: hsl(120 60% 30%);
-    --cru-success-container-1-color: hsl(120 60% 25%);
-    --cru-success-container-2-color: hsl(120 60% 20%);
-    --cru-success-on-container-color: hsl(120 60% 90%);
-    --cru-surface-dim-color: hsl(0 0% 6%);
-    --cru-surface-color: hsl(0 0% 6%);
-    --cru-surface-1-color: hsl(0 0% 25%);
-    --cru-surface-2-color: hsl(0 0% 40%);
-    --cru-surface-bright-color: hsl(0 0% 24%);
-    --cru-surface-container-lowest-color: hsl(0 0% 4%);
-    --cru-surface-container-low-color: hsl(0 0% 10%);
-    --cru-surface-container-color: hsl(0 0% 12%);
-    --cru-surface-container-high-color: hsl(0 0% 17%);
-    --cru-surface-container-highest-color: hsl(0 0% 22%);
-    --cru-surface-on-color: hsl(0 0% 90%);
-    --cru-surface-on-variant-color: hsl(0 0% 80%);
-    --cru-surface-outline-color: hsl(0 0% 60%);
-    --cru-surface-outline-variant-color: hsl(0 0% 30%);
-  }
-}
-
-.cru-primary {
-  --cru-key-color: var(--cru-primary-color);
-  --cru-key-1-color: var(--cru-primary-1-color);
-  --cru-key-2-color: var(--cru-primary-2-color);
-  --cru-key-on-color: var(--cru-primary-on-color);
-  --cru-key-container-color: var(--cru-primary-container-color);
-  --cru-key-container-1-color: var(--cru-primary-container-1-color);
-  --cru-key-container-2-color: var(--cru-primary-container-2-color);
-  --cru-key-on-container-color: var(--cru-primary-on-container-color);
-}
-
-.cru-secondary {
-  --cru-key-color: var(--cru-secondary-color);
-  --cru-key-1-color: var(--cru-secondary-1-color);
-  --cru-key-2-color: var(--cru-secondary-2-color);
-  --cru-key-on-color: var(--cru-secondary-on-color);
-  --cru-key-container-color: var(--cru-secondary-container-color);
-  --cru-key-container-1-color: var(--cru-secondary-container-1-color);
-  --cru-key-container-2-color: var(--cru-secondary-container-2-color);
-  --cru-key-on-container-color: var(--cru-secondary-on-container-color);
-}
-
-.cru-tertiary {
-  --cru-key-color: var(--cru-tertiary-color);
-  --cru-key-1-color: var(--cru-tertiary-1-color);
-  --cru-key-2-color: var(--cru-tertiary-2-color);
-  --cru-key-on-color: var(--cru-tertiary-on-color);
-  --cru-key-container-color: var(--cru-tertiary-container-color);
-  --cru-key-container-1-color: var(--cru-tertiary-container-1-color);
-  --cru-key-container-2-color: var(--cru-tertiary-container-2-color);
-  --cru-key-on-container-color: var(--cru-tertiary-on-container-color);
-}
-
-.cru-danger {
-  --cru-key-color: var(--cru-danger-color);
-  --cru-key-1-color: var(--cru-danger-1-color);
-  --cru-key-2-color: var(--cru-danger-2-color);
-  --cru-key-on-color: var(--cru-danger-on-color);
-  --cru-key-container-color: var(--cru-danger-container-color);
-  --cru-key-container-1-color: var(--cru-danger-container-1-color);
-  --cru-key-container-2-color: var(--cru-danger-container-2-color);
-  --cru-key-on-container-color: var(--cru-danger-on-container-color);
-}
-
-.cru-success {
-  --cru-key-color: var(--cru-success-color);
-  --cru-key-1-color: var(--cru-success-1-color);
-  --cru-key-2-color: var(--cru-success-2-color);
-  --cru-key-on-color: var(--cru-success-on-color);
-  --cru-key-container-color: var(--cru-success-container-color);
-  --cru-key-container-1-color: var(--cru-success-container-1-color);
-  --cru-key-container-2-color: var(--cru-success-container-2-color);
-  --cru-key-on-container-color: var(--cru-success-on-container-color);
-}
-
diff --git a/FrontEnd/src/views/common/theme.css b/FrontEnd/src/views/common/theme.css
deleted file mode 100644
index 6ceb369f..00000000
--- a/FrontEnd/src/views/common/theme.css
+++ /dev/null
@@ -1,146 +0,0 @@
-@import "./theme-color.css";
-
-:root {
-  --cru-default-font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
-  --cru-page-padding: 1em 2em;
-
-  --cru-border-radius: 4px;
-  --cru-card-border-radius: 4px;
-}
-
-/* theme colors */
-:root {
-  --cru-primary-color: hsl(210, 100%, 50%);
-  --cru-secondary-color: hsl(30, 100%, 50%);
-  --cru-create-color: hsl(120, 100%, 25%);
-  --cru-danger-color: hsl(0, 100%, 50%);
-}
-
-/* common colors */
-:root {
-  --cru-background-color: hsl(0, 0%, 100%);
-  --cru-container-background-color: hsl(0, 0%, 97%);
-  --cru-text-primary-color: hsl(0, 0%, 0%);
-  --cru-text-secondary-color: hsl(0, 0%, 38%);
-}
-
-@media (prefers-color-scheme: dark) {
-  :root {
-    --cru-background-color: hsl(0, 0%, 0%);
-    --cru-container-background-color: hsl(0, 0%, 2%);
-    --cru-text-primary-color: hsl(0, 0%, 100%);
-    --cru-text-secondary-color: hsl(0, 0%, 85%);
-  }
-}
-
-:root {
-  --cru-body-background-color: var(--cru-background-color);
-}
-
-/* clickable color */
-:root {
-  --cru-clickable-primary-normal-color: var(--cru-primary-color);
-  --cru-clickable-primary-hover-color: hsl(210, 100%, 60%);
-  --cru-clickable-primary-focus-color: hsl(210, 100%, 60%);
-  --cru-clickable-primary-active-color: hsl(210, 100%, 70%);
-  --cru-clickable-secondary-normal-color: var(--cru-secondary-color);
-  --cru-clickable-secondary-hover-color: hsl(30, 100%, 60%);
-  --cru-clickable-secondary-focus-color: hsl(30, 100%, 60%);
-  --cru-clickable-secondary-active-color: hsl(30, 100%, 70%);
-  --cru-clickable-create-normal-color: var(--cru-create-color);
-  --cru-clickable-create-hover-color: hsl(120, 100%, 35%);
-  --cru-clickable-create-focus-color: hsl(120, 100%, 35%);
-  --cru-clickable-create-active-color: hsl(120, 100%, 35%);
-  --cru-clickable-danger-normal-color: var(--cru-danger-color);
-  --cru-clickable-danger-hover-color: hsl(0, 100%, 60%);
-  --cru-clickable-danger-focus-color: hsl(0, 100%, 60%);
-  --cru-clickable-danger-active-color: hsl(0, 100%, 70%);
-  --cru-clickable-grayscale-normal-color: hsl(0, 0%, 100%);
-  --cru-clickable-grayscale-hover-color: hsl(0, 0%, 92%);
-  --cru-clickable-grayscale-focus-color: hsl(0, 0%, 92%);
-  --cru-clickable-grayscale-active-color: hsl(0, 0%, 88%);
-  --cru-clickable-disabled-color: hsl(0, 0%, 50%);
-}
-
-@media (prefers-color-scheme: dark) {
-  :root {
-    --cru-clickable-grayscale-normal-color: hsl(0, 0%, 0%);
-    --cru-clickable-grayscale-hover-color: hsl(0, 0%, 10%);
-    --cru-clickable-grayscale-focus-color: hsl(0, 0%, 10%);
-    --cru-clickable-grayscale-active-color: hsl(0, 0%, 20%);
-  }
-}
-
-.cru-clickable-primary {
-  --cru-clickable-normal-color: var(--cru-clickable-primary-normal-color);
-  --cru-clickable-hover-color: var(--cru-clickable-primary-hover-color);
-  --cru-clickable-focus-color: var(--cru-clickable-primary-focus-color);
-  --cru-clickable-active-color: var(--cru-clickable-primary-active-color);
-}
-
-.cru-clickable-secondary {
-  --cru-clickable-normal-color: var(--cru-clickable-secondary-normal-color);
-  --cru-clickable-hover-color: var(--cru-clickable-secondary-hover-color);
-  --cru-clickable-focus-color: var(--cru-clickable-secondary-focus-color);
-  --cru-clickable-active-color: var(--cru-clickable-secondary-active-color);
-}
-
-.cru-clickable-create {
-  --cru-clickable-normal-color: var(--cru-clickable-create-normal-color);
-  --cru-clickable-hover-color: var(--cru-clickable-create-hover-color);
-  --cru-clickable-focus-color: var(--cru-clickable-create-focus-color);
-  --cru-clickable-active-color: var(--cru-clickable-create-active-color);
-}
-
-.cru-clickable-danger {
-  --cru-clickable-normal-color: var(--cru-clickable-danger-normal-color);
-  --cru-clickable-hover-color: var(--cru-clickable-danger-hover-color);
-  --cru-clickable-focus-color: var(--cru-clickable-danger-focus-color);
-  --cru-clickable-active-color: var(--cru-clickable-danger-active-color);
-}
-
-.cru-clickable-grayscale {
-  --cru-clickable-normal-color: var(--cru-clickable-grayscale-normal-color);
-  --cru-clickable-hover-color: var(--cru-clickable-grayscale-hover-color);
-  --cru-clickable-focus-color: var(--cru-clickable-grayscale-focus-color);
-  --cru-clickable-active-color: var(--cru-clickable-grayscale-active-color);
-}
-
-/* button colors */
-:root {
-  /* push button colors */
-  --cru-push-button-text-color: #ffffff;
-  --cru-push-button-disabled-text-color: hsl(0, 0%, 80%);
-}
-
-/* Card colors */
-:root {
-  --cru-card-background-primary-color: hsl(210, 100%, 50%);
-  --cru-card-border-primary-color: hsl(210, 100%, 50%);
-  --cru-card-background-secondary-color: hsl(30, 100%, 50%);
-  --cru-card-border-secondary-color: hsl(30, 100%, 50%);
-  --cru-card-background-create-color: hsl(120, 100%, 25%);
-  --cru-card-border-create-color: hsl(120, 100%, 25%);
-  --cru-card-background-danger-color: hsl(0, 100%, 50%);
-  --cru-card-border-danger-color: hsl(0, 100%, 50%);
-}
-
-.cru-card-primary {
-  --cru-card-background-color: var(--cru-card-background-primary-color);
-  --cru-card-border-color: var(--cru-card-border-primary-color)
-}
-
-.cru-card-secondary {
-  --cru-card-background-color: var(--cru-card-background-secondary-color);
-  --cru-card-border-color: var(--cru-card-border-secondary-color)
-}
-
-.cru-card-create {
-  --cru-card-background-color: var(--cru-card-background-create-color);
-  --cru-card-border-color: var(--cru-card-border-create-color)
-}
-
-.cru-card-danger {
-  --cru-card-background-color: var(--cru-card-background-danger-color);
-  --cru-card-border-color: var(--cru-card-border-danger-color)
-}
\ No newline at end of file
diff --git a/FrontEnd/src/views/common/user/UserAvatar.tsx b/FrontEnd/src/views/common/user/UserAvatar.tsx
deleted file mode 100644
index aea7bd48..00000000
--- a/FrontEnd/src/views/common/user/UserAvatar.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import { Ref, ComponentPropsWithoutRef } from "react";
-
-import { getHttpUserClient } from "@/http/user";
-
-export interface UserAvatarProps extends ComponentPropsWithoutRef<"img"> {
-  username: string;
-  imgRef?: Ref<HTMLImageElement> | null;
-}
-
-export default function UserAvatar({
-  username,
-  imgRef,
-  ...otherProps
-}: UserAvatarProps) {
-  return (
-    <img
-      ref={imgRef}
-      src={getHttpUserClient().generateAvatarUrl(username)}
-      {...otherProps}
-    />
-  );
-}
diff --git a/FrontEnd/tsconfig.json b/FrontEnd/tsconfig.json
index cd6aa25d..bf106d95 100644
--- a/FrontEnd/tsconfig.json
+++ b/FrontEnd/tsconfig.json
@@ -21,9 +21,6 @@
       "~*": [
         "./*"
       ],
-      "@/*": [
-        "./src/*"
-      ]
     },
     "noEmit": true
   },
-- 
cgit v1.2.3


From fb64a8f3d4a7dd4035f50ccf2601ae0a683bd1b8 Mon Sep 17 00:00:00 2001
From: crupest <crupest@outlook.com>
Date: Thu, 31 Aug 2023 01:50:40 +0800
Subject: ...

---
 FrontEnd/src/App.tsx                               |   2 +-
 FrontEnd/src/components/alert/AlertHost.tsx        | 126 ++++++++-------------
 FrontEnd/src/components/alert/AlertService.ts      | 111 ++++++++++++++++++
 FrontEnd/src/components/alert/alert.css            |  25 ++--
 FrontEnd/src/components/alert/index.ts             |   8 ++
 FrontEnd/src/pages/setting/index.tsx               |   4 +-
 FrontEnd/src/pages/timeline/TimelineCard.tsx       |   4 +-
 .../src/pages/timeline/TimelinePostCreateView.tsx  |   5 +-
 FrontEnd/src/pages/timeline/TimelinePostView.tsx   |   5 +-
 FrontEnd/src/services/user.ts                      |  15 ++-
 10 files changed, 190 insertions(+), 115 deletions(-)
 create mode 100644 FrontEnd/src/components/alert/AlertService.ts
 create mode 100644 FrontEnd/src/components/alert/index.ts

(limited to 'FrontEnd/src/App.tsx')

diff --git a/FrontEnd/src/App.tsx b/FrontEnd/src/App.tsx
index b5b076ec..58463d08 100644
--- a/FrontEnd/src/App.tsx
+++ b/FrontEnd/src/App.tsx
@@ -10,7 +10,7 @@ import LoginPage from "./pages/login";
 import RegisterPage from "./pages/register";
 import TimelinePage from "./pages/timeline";
 import LoadingPage from "./pages/loading";
-import AlertHost from "./components/alert/AlertHost";
+import { AlertHost } from "./components/alert";
 
 export default function App() {
   return (
diff --git a/FrontEnd/src/components/alert/AlertHost.tsx b/FrontEnd/src/components/alert/AlertHost.tsx
index b234ac03..23f62472 100644
--- a/FrontEnd/src/components/alert/AlertHost.tsx
+++ b/FrontEnd/src/components/alert/AlertHost.tsx
@@ -1,95 +1,56 @@
-import * as React from "react";
-import without from "lodash/without";
-import { useTranslation } from "react-i18next";
+import { useEffect, useState } from "react";
 import classNames from "classnames";
 
-import { alertService, AlertInfoEx, AlertInfo } from "~src/services/alert";
-import { convertI18nText } from "~src/common";
-
+import { ThemeColor, useC, Text } from "../common";
 import IconButton from "../button/IconButton";
 
+import { alertService, AlertInfoWithId } from "./AlertService";
+
 import "./alert.css";
 
 interface AutoCloseAlertProps {
-  alert: AlertInfo;
-  close: () => void;
+  color: ThemeColor;
+  message: Text;
+  onDismiss?: () => void;
+  onIn?: () => void;
+  onOut?: () => void;
 }
 
-export const AutoCloseAlert: React.FC<AutoCloseAlertProps> = (props) => {
-  const { alert, close } = props;
-  const { dismissTime } = alert;
-
-  const { t } = useTranslation();
-
-  const timerTag = React.useRef<number | null>(null);
-  const closeHandler = React.useRef<(() => void) | null>(null);
-
-  React.useEffect(() => {
-    closeHandler.current = close;
-  }, [close]);
-
-  React.useEffect(() => {
-    const tag =
-      dismissTime === "never"
-        ? null
-        : typeof dismissTime === "number"
-        ? window.setTimeout(() => closeHandler.current?.(), dismissTime)
-        : window.setTimeout(() => closeHandler.current?.(), 5000);
-    timerTag.current = tag;
-    return () => {
-      if (tag != null) {
-        window.clearTimeout(tag);
-      }
-    };
-  }, [dismissTime]);
-
-  const cancelTimer = (): void => {
-    const { current: tag } = timerTag;
-    if (tag != null) {
-      window.clearTimeout(tag);
-    }
-  };
+function Alert({
+  color,
+  message,
+  onDismiss,
+  onIn,
+  onOut,
+}: AutoCloseAlertProps) {
+  const c = useC();
 
   return (
     <div
-      className={classNames(
-        "m-3 cru-alert",
-        "cru-" + (alert.type ?? "primary")
-      )}
-      onClick={cancelTimer}
+      className={classNames("cru-alert", `cru-theme-${color}`)}
+      onPointerEnter={onIn}
+      onPointerLeave={onOut}
     >
-      <div className="cru-alert-content">
-        {(() => {
-          const { message, customMessage } = alert;
-          if (customMessage != null) {
-            return customMessage;
-          } else {
-            return convertI18nText(message, t);
-          }
-        })()}
-      </div>
-      <div className="cru-alert-close-button-container">
-        <IconButton
-          icon="x"
-          className="cru-alert-close-button"
-          onClick={close}
-        />
-      </div>
+      <div className="cru-alert-message">{c(message)}</div>
+      <IconButton
+        icon="x"
+        color="danger"
+        className="cru-alert-close-button"
+        onClick={onDismiss}
+      />
     </div>
   );
-};
+}
 
-const AlertHost: React.FC = () => {
-  const [alerts, setAlerts] = React.useState<AlertInfoEx[]>([]);
+export default function AlertHost() {
+  const [alerts, setAlerts] = useState<AlertInfoWithId[]>([]);
 
-  React.useEffect(() => {
-    const consume = (alert: AlertInfoEx): void => {
-      setAlerts((old) => [...old, alert]);
-    };
+  useEffect(() => {
+    alertService.registerListener(setAlerts);
 
-    alertService.registerConsumer(consume);
     return () => {
-      alertService.unregisterConsumer(consume);
+      alertService.unregisterListener(setAlerts);
+      alert;
     };
   }, []);
 
@@ -97,17 +58,22 @@ const AlertHost: React.FC = () => {
     <div className="alert-container">
       {alerts.map((alert) => {
         return (
-          <AutoCloseAlert
+          <Alert
             key={alert.id}
-            alert={alert}
-            close={() => {
-              setAlerts((old) => without(old, alert));
+            message={alert.message}
+            color={alert.color ?? "primary"}
+            onIn={() => {
+              alertService.clearDismissTimer(alert.id);
+            }}
+            onOut={() => {
+              alertService.resetDismissTimer(alert.id);
+            }}
+            onDismiss={() => {
+              alertService.dismiss(alert.id);
             }}
           />
         );
       })}
     </div>
   );
-};
-
-export default AlertHost;
+}
diff --git a/FrontEnd/src/components/alert/AlertService.ts b/FrontEnd/src/components/alert/AlertService.ts
new file mode 100644
index 00000000..b3565b8a
--- /dev/null
+++ b/FrontEnd/src/components/alert/AlertService.ts
@@ -0,0 +1,111 @@
+import { ThemeColor, Text } from "../common";
+
+export interface AlertInfo {
+  color?: ThemeColor;
+  message: Text;
+  dismissTime?: number | "never";
+}
+
+export interface AlertInfoWithId extends AlertInfo {
+  id: number;
+}
+
+interface AlertServiceAlert extends AlertInfoWithId {
+  timerId: number | null;
+}
+
+export type AlertsListener = (alerts: AlertInfoWithId[]) => void;
+
+export class AlertService {
+  private listeners: AlertsListener[] = [];
+  private alerts: AlertServiceAlert[] = [];
+  private currentId = 1;
+
+  getAlert(alertId?: number | null | undefined): AlertServiceAlert | null {
+    for (const alert of this.alerts) {
+      if (alert.id === alertId) return alert;
+    }
+    return null;
+  }
+
+  registerListener(listener: AlertsListener): void {
+    this.listeners.push(listener);
+    listener(this.alerts);
+  }
+
+  unregisterListener(listener: AlertsListener): void {
+    this.listeners = this.listeners.filter((l) => l !== listener);
+  }
+
+  notify() {
+    for (const listener of this.listeners) {
+      listener(this.alerts);
+    }
+  }
+
+  push(alert: AlertInfo): void {
+    const newAlert: AlertServiceAlert = {
+      ...alert,
+      id: this.currentId++,
+      timerId: null,
+    };
+
+    this.alerts.push(newAlert);
+    this._resetDismissTimer(newAlert);
+
+    this.notify();
+  }
+
+  private _dismiss(alert: AlertServiceAlert) {
+    if (alert.timerId != null) {
+      window.clearTimeout(alert.timerId);
+    }
+    this.alerts = this.alerts.filter((a) => a !== alert);
+    this.notify();
+  }
+
+  dismiss(alertId?: number | null | undefined) {
+    const alert = this.getAlert(alertId);
+    if (alert != null) {
+      this._dismiss(alert);
+    }
+  }
+
+  private _clearDismissTimer(alert: AlertServiceAlert) {
+    if (alert.timerId != null) {
+      window.clearTimeout(alert.timerId);
+      alert.timerId = null;
+    }
+  }
+
+  clearDismissTimer(alertId?: number | null | undefined) {
+    const alert = this.getAlert(alertId);
+    if (alert != null) {
+      this._clearDismissTimer(alert);
+    }
+  }
+
+  private _resetDismissTimer(
+    alert: AlertServiceAlert,
+    dismissTime?: number | null | undefined,
+  ) {
+    this._clearDismissTimer(alert);
+
+    const realDismissTime = dismissTime ?? alert.dismissTime;
+
+    if (typeof realDismissTime === "number") {
+      alert.timerId = window.setTimeout(() => {
+        this._dismiss(alert);
+      }, realDismissTime);
+    }
+  }
+
+  resetDismissTimer(alertId?: number | null | undefined) {
+    const alert = this.getAlert(alertId);
+    if (alert != null) {
+      this._resetDismissTimer(alert);
+    }
+  }
+}
+
+export const alertService = new AlertService();
diff --git a/FrontEnd/src/components/alert/alert.css b/FrontEnd/src/components/alert/alert.css
index 54c2b87f..063af933 100644
--- a/FrontEnd/src/components/alert/alert.css
+++ b/FrontEnd/src/components/alert/alert.css
@@ -5,29 +5,18 @@
 
 .cru-alert {
   border-radius: 5px;
-  border: var(--cru-key-color) 1px solid;
-  color: var(--cru-key-t-color);
-  background-color: var(--cru-key-b1-color);
+  border: var(--cru-theme-color) 1px solid;
+  color: var(--cru-text-primary-color);
+  background-color: var(--cru-container-background-color);
 
-  display: flex;
-  overflow: hidden;
-}
-
-.cru-alert-content {
   padding: 0.5em 2em;
-}
 
-.cru-alert-close-button-container {
-  flex-shrink: 0;
-  margin-left: auto;
-  width: 2em;
-  text-align: center;
   display: flex;
   align-items: center;
-  justify-content: center;
-  background-color: var(--cru-key-t-color);
 }
 
+.cru-alert-message {}
+
 .cru-alert-close-button {
-  color: var(--cru-key-color);
-}
+  margin-left: auto;
+}
\ No newline at end of file
diff --git a/FrontEnd/src/components/alert/index.ts b/FrontEnd/src/components/alert/index.ts
new file mode 100644
index 00000000..1be0c2ec
--- /dev/null
+++ b/FrontEnd/src/components/alert/index.ts
@@ -0,0 +1,8 @@
+import { alertService, AlertInfo } from "./AlertService";
+import { default as AlertHost } from "./AlertHost";
+
+export { alertService, AlertHost };
+
+export function pushAlert(alert: AlertInfo): void {
+  alertService.push(alert);
+}
diff --git a/FrontEnd/src/pages/setting/index.tsx b/FrontEnd/src/pages/setting/index.tsx
index d2333134..25434339 100644
--- a/FrontEnd/src/pages/setting/index.tsx
+++ b/FrontEnd/src/pages/setting/index.tsx
@@ -10,10 +10,10 @@ import classNames from "classnames";
 
 import { useUser, userService } from "~src/services/user";
 import { getHttpUserClient } from "~src/http/user";
-import { pushAlert } from "~src/services/alert";
 
 import { useC, Text } from "~src/common";
 
+import { pushAlert } from "~src/components/alert";
 import {
   useDialog,
   DialogProvider,
@@ -192,7 +192,7 @@ function RegisterCodeSettingItem() {
           onClick={(event) => {
             void navigator.clipboard.writeText(registerCode).then(() => {
               pushAlert({
-                type: "create",
+                color: "create",
                 message: "settings.myRegisterCodeCopied",
               });
             });
diff --git a/FrontEnd/src/pages/timeline/TimelineCard.tsx b/FrontEnd/src/pages/timeline/TimelineCard.tsx
index 44dfbf9e..1e0e9b75 100644
--- a/FrontEnd/src/pages/timeline/TimelineCard.tsx
+++ b/FrontEnd/src/pages/timeline/TimelineCard.tsx
@@ -2,11 +2,11 @@ import { useState } from "react";
 import { HubConnectionState } from "@microsoft/signalr";
 
 import { useUser } from "~src/services/user";
-import { pushAlert } from "~src/services/alert";
 
 import { HttpTimelineInfo } from "~src/http/timeline";
 import { getHttpBookmarkClient } from "~src/http/bookmark";
 
+import { pushAlert } from "~src/components/alert";
 import { useMobile } from "~src/components/hooks";
 import { Dialog, DialogProvider, useDialog } from "~src/components/dialog";
 import UserAvatar from "~src/components/user/UserAvatar";
@@ -89,7 +89,7 @@ export default function TimelineCard(props: TimelinePageCardProps) {
                     message: timeline.isBookmark
                       ? "timeline.removeBookmarkFail"
                       : "timeline.addBookmarkFail",
-                    type: "danger",
+                    color: "danger",
                   });
                 });
             }}
diff --git a/FrontEnd/src/pages/timeline/TimelinePostCreateView.tsx b/FrontEnd/src/pages/timeline/TimelinePostCreateView.tsx
index 3bc4dab3..70925cd9 100644
--- a/FrontEnd/src/pages/timeline/TimelinePostCreateView.tsx
+++ b/FrontEnd/src/pages/timeline/TimelinePostCreateView.tsx
@@ -10,10 +10,9 @@ import {
   HttpTimelinePostPostRequestData,
 } from "~src/http/timeline";
 
-import { pushAlert } from "~src/services/alert";
-
 import base64 from "~src/utilities/base64";
 
+import { pushAlert } from "~src/components/alert";
 import BlobImage from "~src/components/BlobImage";
 import LoadingButton from "~src/components/button/LoadingButton";
 import PopupMenu from "~src/components/menu/PopupMenu";
@@ -141,7 +140,7 @@ function TimelinePostEdit(props: TimelinePostEditProps) {
 
   const onPostError = (): void => {
     pushAlert({
-      type: "danger",
+      color: "danger",
       message: "timeline.sendPostFailed",
     });
   };
diff --git a/FrontEnd/src/pages/timeline/TimelinePostView.tsx b/FrontEnd/src/pages/timeline/TimelinePostView.tsx
index 5de09b28..b09fe6f8 100644
--- a/FrontEnd/src/pages/timeline/TimelinePostView.tsx
+++ b/FrontEnd/src/pages/timeline/TimelinePostView.tsx
@@ -5,8 +5,7 @@ import {
   HttpTimelinePostInfo,
 } from "~src/http/timeline";
 
-import { pushAlert } from "~src/services/alert";
-
+import { pushAlert } from "~src/components/alert";
 import { useClickOutside } from "~src/components/hooks";
 import UserAvatar from "~src/components/user/UserAvatar";
 import { DialogProvider, useDialog } from "~src/components/dialog";
@@ -44,7 +43,7 @@ export default function TimelinePostView(props: TimelinePostViewProps) {
               .deletePost(post.timelineOwnerV2, post.timelineNameV2, post.id)
               .then(onDeleted, () => {
                 pushAlert({
-                  type: "danger",
+                  color: "danger",
                   message: "timeline.deletePostFailed",
                 });
               });
diff --git a/FrontEnd/src/services/user.ts b/FrontEnd/src/services/user.ts
index ddba4dab..5f682a36 100644
--- a/FrontEnd/src/services/user.ts
+++ b/FrontEnd/src/services/user.ts
@@ -8,14 +8,17 @@ import { setHttpToken, axios, HttpBadRequestError } from "~src/http/common";
 import { getHttpTokenClient } from "~src/http/token";
 import { getHttpUserClient, HttpUser, UserPermission } from "~src/http/user";
 
-import { pushAlert } from "./alert";
+import { pushAlert } from "~src/components/alert";
 
 interface IAuthUser extends HttpUser {
   token: string;
 }
 
 export class AuthUser implements IAuthUser {
-  constructor(user: HttpUser, public token: string) {
+  constructor(
+    user: HttpUser,
+    public token: string,
+  ) {
     this.uniqueId = user.uniqueId;
     this.username = user.username;
     this.permissions = user.permissions;
@@ -61,7 +64,7 @@ export class UserService {
       if (e.isAxiosError && e.response && e.response.status === 401) {
         this.userSubject.next(null);
         pushAlert({
-          type: "danger",
+          color: "danger",
           message: "user.tokenInvalid",
         });
       } else {
@@ -97,11 +100,11 @@ export class UserService {
               localStorage.removeItem(USER_STORAGE_KEY);
               this.userSubject.next(null);
               pushAlert({
-                type: "danger",
+                color: "danger",
                 message: "user.tokenInvalid",
               });
             }
-          }
+          },
         );
     }
   }
@@ -118,7 +121,7 @@ export class UserService {
 
   async login(
     credentials: LoginCredentials,
-    rememberMe: boolean
+    rememberMe: boolean,
   ): Promise<void> {
     if (this.currentUser) {
       throw new UiLogicError("Already login.");
-- 
cgit v1.2.3

