From 47587812b809fee2a95c76266d9d0e42fc4ac1ca Mon Sep 17 00:00:00 2001
From: crupest <crupest@outlook.com>
Date: Tue, 15 Jun 2021 14:14:28 +0800
Subject: ...

---
 FrontEnd/src/views/admin/UserAdmin.tsx | 396 +++++++++++++++++++++++++++++++++
 1 file changed, 396 insertions(+)
 create mode 100644 FrontEnd/src/views/admin/UserAdmin.tsx

(limited to 'FrontEnd/src/views/admin/UserAdmin.tsx')

diff --git a/FrontEnd/src/views/admin/UserAdmin.tsx b/FrontEnd/src/views/admin/UserAdmin.tsx
new file mode 100644
index 00000000..4e9cd600
--- /dev/null
+++ b/FrontEnd/src/views/admin/UserAdmin.tsx
@@ -0,0 +1,396 @@
+import React, { useState, useEffect } from "react";
+import classnames from "classnames";
+import { ListGroup, Row, Col, Spinner, Button } from "react-bootstrap";
+
+import OperationDialog, {
+  OperationDialogBoolInput,
+} from "../common/OperationDialog";
+
+import { AuthUser } from "@/services/user";
+import {
+  getHttpUserClient,
+  HttpUser,
+  kUserPermissionList,
+  UserPermission,
+} from "http/user";
+import { Trans, useTranslation } from "react-i18next";
+
+interface DialogProps<TData = undefined, TReturn = undefined> {
+  open: boolean;
+  close: () => void;
+  data: TData;
+  onSuccess: (data: TReturn) => void;
+}
+
+const CreateUserDialog: React.FC<DialogProps<undefined, HttpUser>> = ({
+  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,
+        })
+      }
+      close={close}
+      open={open}
+      onSuccessAndClose={onSuccess}
+    />
+  );
+};
+
+const UsernameLabel: React.FC = (props) => {
+  return <span style={{ color: "blue" }}>{props.children}</span>;
+};
+
+const UserDeleteDialog: React.FC<DialogProps<{ username: string }, unknown>> =
+  ({ open, close, data: { username }, onSuccess }) => {
+    return (
+      <OperationDialog
+        open={open}
+        close={close}
+        title="admin:user.dialog.delete.title"
+        themeColor="danger"
+        inputPrompt={() => (
+          <Trans i18nKey="admin:user.dialog.delete.prompt">
+            0<UsernameLabel>{username}</UsernameLabel>2
+          </Trans>
+        )}
+        onProcess={() => getHttpUserClient().delete(username)}
+        onSuccessAndClose={onSuccess}
+      />
+    );
+  };
+
+const UserModifyDialog: React.FC<
+  DialogProps<
+    {
+      oldUser: HttpUser;
+    },
+    HttpUser
+  >
+> = ({ open, close, data: { oldUser }, onSuccess }) => {
+  return (
+    <OperationDialog
+      open={open}
+      close={close}
+      title="admin:user.dialog.modify.title"
+      themeColor="danger"
+      inputPrompt={() => (
+        <Trans i18nKey="admin:user.dialog.modify.prompt">
+          0<UsernameLabel>{oldUser.username}</UsernameLabel>2
+        </Trans>
+      )}
+      inputScheme={
+        [
+          {
+            type: "text",
+            label: "admin:user.username",
+            initValue: oldUser.username,
+          },
+          { type: "text", label: "admin:user.password" },
+          {
+            type: "text",
+            label: "admin:user.nickname",
+            initValue: oldUser.nickname,
+          },
+        ] as const
+      }
+      onProcess={([username, password, nickname]) =>
+        getHttpUserClient().patch(oldUser.username, {
+          username: username !== oldUser.username ? username : undefined,
+          password: password !== "" ? password : undefined,
+          nickname: nickname !== oldUser.nickname ? nickname : undefined,
+        })
+      }
+      onSuccessAndClose={onSuccess}
+    />
+  );
+};
+
+const UserPermissionModifyDialog: React.FC<
+  DialogProps<
+    {
+      username: string;
+      permissions: UserPermission[];
+    },
+    UserPermission[]
+  >
+> = ({ open, close, data: { username, permissions }, onSuccess }) => {
+  const oldPermissionBoolList: boolean[] = kUserPermissionList.map(
+    (permission) => permissions.includes(permission)
+  );
+
+  return (
+    <OperationDialog
+      open={open}
+      close={close}
+      title="admin:user.dialog.modifyPermissions.title"
+      themeColor="danger"
+      inputPrompt={() => (
+        <Trans i18nKey="admin:user.dialog.modifyPermissions.prompt">
+          0<UsernameLabel>{username}</UsernameLabel>2
+        </Trans>
+      )}
+      inputScheme={kUserPermissionList.map<OperationDialogBoolInput>(
+        (permission, index) => ({
+          type: "bool",
+          label: 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(username, permission);
+          } else {
+            await getHttpUserClient().deleteUserPermission(
+              username,
+              permission
+            );
+          }
+        }
+        return newPermissionBoolList;
+      }}
+      onSuccessAndClose={(newPermissionBoolList: boolean[]) => {
+        const permissions: UserPermission[] = [];
+        for (let index = 0; index < kUserPermissionList.length; index++) {
+          if (newPermissionBoolList[index]) {
+            permissions.push(kUserPermissionList[index]);
+          }
+        }
+        onSuccess(permissions);
+      }}
+    />
+  );
+};
+
+const kModify = "modify";
+const kModifyPermission = "permission";
+const kDelete = "delete";
+
+type TModify = typeof kModify;
+type TModifyPermission = typeof kModifyPermission;
+type TDelete = typeof kDelete;
+
+type ContextMenuItem = TModify | TModifyPermission | TDelete;
+
+interface UserItemProps {
+  on: { [key in ContextMenuItem]: () => void };
+  user: HttpUser;
+}
+
+const UserItem: React.FC<UserItemProps> = ({ user, on }) => {
+  const { t } = useTranslation();
+
+  const [editMaskVisible, setEditMaskVisible] = React.useState<boolean>(false);
+
+  return (
+    <ListGroup.Item className="admin-user-item">
+      <i
+        className="bi-pencil-square float-end icon-button text-warning"
+        onClick={() => setEditMaskVisible(true)}
+      />
+      <h4 className="text-primary">{user.username}</h4>
+      <div className="text-secondary">
+        {t("admin:user.nickname")}
+        {user.nickname}
+      </div>
+      <div className="text-secondary">
+        {t("admin:user.uniqueId")}
+        {user.uniqueId}
+      </div>
+      <div className="text-secondary">
+        {t("admin:user.permissions")}
+        {user.permissions.map((permission) => {
+          return (
+            <span key={permission} className="text-danger">
+              {permission}{" "}
+            </span>
+          );
+        })}
+      </div>
+      <div
+        className={classnames("edit-mask", !editMaskVisible && "d-none")}
+        onClick={() => setEditMaskVisible(false)}
+      >
+        <button className="text-button primary" onClick={on[kModify]}>
+          {t("admin:user.modify")}
+        </button>
+        <button className="text-button primary" onClick={on[kModifyPermission]}>
+          {t("admin:user.modifyPermissions")}
+        </button>
+        <button className="text-button danger" onClick={on[kDelete]}>
+          {t("admin:user.delete")}
+        </button>
+      </div>
+    </ListGroup.Item>
+  );
+};
+
+interface UserAdminProps {
+  user: AuthUser;
+}
+
+const UserAdmin: React.FC<UserAdminProps> = () => {
+  const { t } = useTranslation();
+
+  type DialogInfo =
+    | null
+    | {
+        type: "create";
+      }
+    | {
+        type: TModify;
+        user: HttpUser;
+      }
+    | {
+        type: TModifyPermission;
+        username: string;
+        permissions: UserPermission[];
+      }
+    | { type: TDelete; username: string };
+
+  const [users, setUsers] = useState<HttpUser[] | null>(null);
+  const [dialog, setDialog] = useState<DialogInfo>(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);
+        }
+      });
+    return () => {
+      subscribe = false;
+    };
+  }, [usersVersion]);
+
+  let dialogNode: React.ReactNode;
+  if (dialog) {
+    switch (dialog.type) {
+      case "create":
+        dialogNode = (
+          <CreateUserDialog
+            open
+            close={() => setDialog(null)}
+            data={undefined}
+            onSuccess={updateUsers}
+          />
+        );
+        break;
+      case kDelete:
+        dialogNode = (
+          <UserDeleteDialog
+            open
+            close={() => setDialog(null)}
+            data={{ username: dialog.username }}
+            onSuccess={updateUsers}
+          />
+        );
+        break;
+      case kModify:
+        dialogNode = (
+          <UserModifyDialog
+            open
+            close={() => setDialog(null)}
+            data={{ oldUser: dialog.user }}
+            onSuccess={updateUsers}
+          />
+        );
+        break;
+      case kModifyPermission:
+        dialogNode = (
+          <UserPermissionModifyDialog
+            open
+            close={() => setDialog(null)}
+            data={{
+              username: dialog.username,
+              permissions: dialog.permissions,
+            }}
+            onSuccess={updateUsers}
+          />
+        );
+        break;
+    }
+  }
+
+  if (users) {
+    const userComponents = users.map((user) => {
+      return (
+        <UserItem
+          key={user.username}
+          user={user}
+          on={{
+            modify: () => {
+              setDialog({
+                type: "modify",
+                user,
+              });
+            },
+            permission: () => {
+              setDialog({
+                type: kModifyPermission,
+                username: user.username,
+                permissions: user.permissions,
+              });
+            },
+            delete: () => {
+              setDialog({
+                type: "delete",
+                username: user.username,
+              });
+            },
+          }}
+        />
+      );
+    });
+
+    return (
+      <>
+        <Row className="justify-content-end my-2">
+          <Col xs="auto">
+            <Button
+              variant="outline-success"
+              onClick={() =>
+                setDialog({
+                  type: "create",
+                })
+              }
+            >
+              {t("admin:create")}
+            </Button>
+          </Col>
+        </Row>
+        {userComponents}
+        {dialogNode}
+      </>
+    );
+  } else {
+    return <Spinner animation="border" />;
+  }
+};
+
+export default UserAdmin;
-- 
cgit v1.2.3


From e0b766203d7576ab67b16ba556ba14120d0bc876 Mon Sep 17 00:00:00 2001
From: crupest <crupest@outlook.com>
Date: Tue, 15 Jun 2021 15:02:49 +0800
Subject: ...

---
 BackEnd/Timeline/FrontEndMode.cs                   |  1 -
 BackEnd/Timeline/Properties/launchSettings.json    |  2 +-
 BackEnd/Timeline/Startup.cs                        |  4 ---
 FrontEnd/index.html                                | 29 ++++++++++++++++++++++
 FrontEnd/package.json                              |  5 ++--
 FrontEnd/postcss.config.js                         | 11 +++-----
 FrontEnd/src/index.ejs                             | 29 ----------------------
 FrontEnd/src/services/TimelinePostBuilder.ts       |  4 +--
 FrontEnd/src/services/timeline.ts                  |  4 +--
 FrontEnd/src/services/user.ts                      |  6 ++---
 FrontEnd/src/tsconfig.json                         | 23 -----------------
 FrontEnd/src/views/admin/UserAdmin.tsx             |  2 +-
 FrontEnd/src/views/center/CenterBoards.tsx         |  6 ++---
 FrontEnd/src/views/center/TimelineBoard.tsx        |  4 +--
 FrontEnd/src/views/center/TimelineCreateDialog.tsx |  2 +-
 FrontEnd/src/views/common/user/UserAvatar.tsx      |  2 +-
 FrontEnd/src/views/home/TimelineListView.tsx       |  4 +--
 FrontEnd/src/views/home/index.tsx                  |  4 +--
 FrontEnd/src/views/search/index.tsx                | 10 ++++----
 FrontEnd/src/views/settings/ChangeAvatarDialog.tsx |  2 +-
 .../src/views/settings/ChangeNicknameDialog.tsx    |  2 +-
 .../timeline-common/ConnectionStatusBadge.tsx      |  2 +-
 .../src/views/timeline-common/MarkdownPostEdit.tsx |  2 +-
 .../timeline-common/PostPropertyChangeDialog.tsx   |  2 +-
 FrontEnd/src/views/timeline-common/Timeline.tsx    |  6 ++---
 .../src/views/timeline-common/TimelineMember.tsx   |  8 +++---
 .../timeline-common/TimelinePageCardTemplate.tsx   |  4 +--
 .../views/timeline-common/TimelinePageTemplate.tsx |  4 +--
 .../timeline-common/TimelinePagedPostListView.tsx  |  2 +-
 .../timeline-common/TimelinePostContentView.tsx    |  4 +--
 .../src/views/timeline-common/TimelinePostEdit.tsx |  6 ++---
 .../views/timeline-common/TimelinePostListView.tsx |  2 +-
 .../src/views/timeline-common/TimelinePostView.tsx |  2 +-
 .../TimelinePropertyChangeDialog.tsx               |  2 +-
 FrontEnd/src/views/timeline/TimelineCard.tsx       |  2 +-
 .../src/views/timeline/TimelineDeleteDialog.tsx    |  2 +-
 FrontEnd/src/views/user/index.tsx                  |  2 +-
 FrontEnd/tsconfig.json                             | 25 +++++++++++++++++++
 FrontEnd/vite.config.js                            |  3 +++
 39 files changed, 116 insertions(+), 120 deletions(-)
 create mode 100644 FrontEnd/index.html
 delete mode 100644 FrontEnd/src/index.ejs
 delete mode 100644 FrontEnd/src/tsconfig.json
 create mode 100644 FrontEnd/tsconfig.json

(limited to 'FrontEnd/src/views/admin/UserAdmin.tsx')

diff --git a/BackEnd/Timeline/FrontEndMode.cs b/BackEnd/Timeline/FrontEndMode.cs
index 63503292..eb718028 100644
--- a/BackEnd/Timeline/FrontEndMode.cs
+++ b/BackEnd/Timeline/FrontEndMode.cs
@@ -4,7 +4,6 @@
     {
         Disable,
         Mock,
-        Proxy,
         Normal
     }
 }
diff --git a/BackEnd/Timeline/Properties/launchSettings.json b/BackEnd/Timeline/Properties/launchSettings.json
index f683ca2d..7f68b709 100644
--- a/BackEnd/Timeline/Properties/launchSettings.json
+++ b/BackEnd/Timeline/Properties/launchSettings.json
@@ -4,7 +4,7 @@
       "commandName": "Project",
       "environmentVariables": {
         "ASPNETCORE_ENVIRONMENT": "Development",
-        "ASPNETCORE_FRONTEND": "Proxy"
+        "ASPNETCORE_FRONTEND": "Disable"
       }
     },
     "Dev-Mock": {
diff --git a/BackEnd/Timeline/Startup.cs b/BackEnd/Timeline/Startup.cs
index 18097e2c..26ffb4b1 100644
--- a/BackEnd/Timeline/Startup.cs
+++ b/BackEnd/Timeline/Startup.cs
@@ -166,10 +166,6 @@ namespace Timeline
             {
                 app.UseSpa(spa =>
                 {
-                    if (_frontEndMode == FrontEndMode.Proxy)
-                    {
-                        spa.UseProxyToSpaDevelopmentServer(new UriBuilder("http", "localhost", 3000).Uri);
-                    }
                 });
             }
         }
diff --git a/FrontEnd/index.html b/FrontEnd/index.html
new file mode 100644
index 00000000..87e19743
--- /dev/null
+++ b/FrontEnd/index.html
@@ -0,0 +1,29 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8" />
+    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
+    <meta name="viewport" content="width=device-width,initial-scale=1.0" />
+
+    <link rel="icon" href="/favicon.ico" />
+    <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
+    <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
+    <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
+    <link rel="manifest" href="/site.webmanifest" />
+    <link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5" />
+    <meta name="msapplication-TileColor" content="#2d89ef" />
+    <meta name="theme-color" content="#ffffff" />
+
+    <title>Timeline</title>
+  </head>
+  <body>
+    <noscript>
+      <strong>
+        We're sorry but Timeline doesn't work properly without JavaScript
+        enabled. Please enable it to continue.
+      </strong>
+    </noscript>
+    <div id="app"></div>
+    <script type="module" src="/src/index.tsx"></script>
+  </body>
+</html>
diff --git a/FrontEnd/package.json b/FrontEnd/package.json
index 1afb5ad0..5d12899e 100644
--- a/FrontEnd/package.json
+++ b/FrontEnd/package.json
@@ -33,8 +33,9 @@
     "xregexp": "^5.0.2"
   },
   "scripts": {
-    "start": "webpack serve --config ./webpack.config.dev.js",
-    "build": "webpack --config ./webpack.config.prod.js",
+    "start": "vite",
+    "build": "tsc && vite build",
+    "preview": "vite preview",
     "lint": "eslint src/ --ext .js --ext .jsx --ext .ts --ext .tsx",
     "lint:fix": "eslint src/ --ext .js --ext .jsx --ext .ts --ext .tsx --fix"
   },
diff --git a/FrontEnd/postcss.config.js b/FrontEnd/postcss.config.js
index 74ee8155..9129aa1f 100644
--- a/FrontEnd/postcss.config.js
+++ b/FrontEnd/postcss.config.js
@@ -1,10 +1,5 @@
+import postcssPresetEnv from "postcss-preset-env";
+
 module.exports = {
-  plugins: [
-    [
-      "postcss-preset-env",
-      {
-        // Options
-      },
-    ],
-  ],
+  plugins: [postcssPresetEnv()],
 };
diff --git a/FrontEnd/src/index.ejs b/FrontEnd/src/index.ejs
deleted file mode 100644
index c2ff4182..00000000
--- a/FrontEnd/src/index.ejs
+++ /dev/null
@@ -1,29 +0,0 @@
-<!DOCTYPE html>
-<html lang="en">
-  <head>
-    <meta charset="utf-8" />
-    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
-    <meta name="viewport" content="width=device-width,initial-scale=1.0" />
-
-    <link rel="icon" href="/favicon.ico" />
-    <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
-    <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
-    <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
-    <link rel="manifest" href="/site.webmanifest" />
-    <link rel="mask-icon" href="/safari-pinned-tab.svg" color="#5bbad5" />
-    <meta name="msapplication-TileColor" content="#2d89ef" />
-    <meta name="theme-color" content="#ffffff" />
-
-    <title><%= htmlWebpackPlugin.options.title %></title>
-  </head>
-  <body>
-    <noscript>
-      <strong>
-        We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work
-        properly without JavaScript enabled. Please enable it to continue.
-      </strong>
-    </noscript>
-    <div id="app"></div>
-    <!-- built files will be auto injected -->
-  </body>
-</html>
diff --git a/FrontEnd/src/services/TimelinePostBuilder.ts b/FrontEnd/src/services/TimelinePostBuilder.ts
index fe4c7a9a..40279eca 100644
--- a/FrontEnd/src/services/TimelinePostBuilder.ts
+++ b/FrontEnd/src/services/TimelinePostBuilder.ts
@@ -2,8 +2,8 @@ import { Remarkable } from "remarkable";
 
 import { UiLogicError } from "@/common";
 
-import { base64 } from "http/common";
-import { HttpTimelinePostPostRequest } from "http/timeline";
+import { base64 } from "@/http/common";
+import { HttpTimelinePostPostRequest } from "@/http/timeline";
 
 export default class TimelinePostBuilder {
   private _onChange: () => void;
diff --git a/FrontEnd/src/services/timeline.ts b/FrontEnd/src/services/timeline.ts
index 4ebb705d..d8c0ae00 100644
--- a/FrontEnd/src/services/timeline.ts
+++ b/FrontEnd/src/services/timeline.ts
@@ -1,9 +1,9 @@
-import { TimelineVisibility } from "http/timeline";
+import { TimelineVisibility } from "@/http/timeline";
 import XRegExp from "xregexp";
 import { Observable } from "rxjs";
 import { HubConnectionBuilder, HubConnectionState } from "@microsoft/signalr";
 
-import { getHttpToken } from "http/common";
+import { getHttpToken } from "@/http/common";
 
 const timelineNameReg = XRegExp("^[-_\\p{L}]*$", "u");
 
diff --git a/FrontEnd/src/services/user.ts b/FrontEnd/src/services/user.ts
index 3375c88a..9a8e5687 100644
--- a/FrontEnd/src/services/user.ts
+++ b/FrontEnd/src/services/user.ts
@@ -3,12 +3,12 @@ import { BehaviorSubject, Observable } from "rxjs";
 
 import { UiLogicError } from "@/common";
 
-import { HttpNetworkError, setHttpToken } from "http/common";
+import { HttpNetworkError, setHttpToken } from "@/http/common";
 import {
   getHttpTokenClient,
   HttpCreateTokenBadCredentialError,
-} from "http/token";
-import { getHttpUserClient, HttpUser, UserPermission } from "http/user";
+} from "@/http/token";
+import { getHttpUserClient, HttpUser, UserPermission } from "@/http/user";
 
 import { pushAlert } from "./alert";
 
diff --git a/FrontEnd/src/tsconfig.json b/FrontEnd/src/tsconfig.json
deleted file mode 100644
index 817c50bd..00000000
--- a/FrontEnd/src/tsconfig.json
+++ /dev/null
@@ -1,23 +0,0 @@
-{
-  "compilerOptions": {
-    "allowJs": true,
-    "skipLibCheck": true,
-    "esModuleInterop": true,
-    "allowSyntheticDefaultImports": true,
-    "strict": true,
-    "forceConsistentCasingInFileNames": true,
-    "module": "esnext",
-    "moduleResolution": "node",
-    "resolveJsonModule": true,
-    "isolatedModules": true,
-    "jsx": "react",
-    "types": ["vite/client"],
-    "sourceMap": true,
-    "baseUrl": "./",
-    "paths": {
-      "@/*": ["*"]
-    },
-    "lib": ["dom", "dom.iterable", "esnext"]
-  },
-  "include": ["."]
-}
diff --git a/FrontEnd/src/views/admin/UserAdmin.tsx b/FrontEnd/src/views/admin/UserAdmin.tsx
index 4e9cd600..558d3aee 100644
--- a/FrontEnd/src/views/admin/UserAdmin.tsx
+++ b/FrontEnd/src/views/admin/UserAdmin.tsx
@@ -12,7 +12,7 @@ import {
   HttpUser,
   kUserPermissionList,
   UserPermission,
-} from "http/user";
+} from "@/http/user";
 import { Trans, useTranslation } from "react-i18next";
 
 interface DialogProps<TData = undefined, TReturn = undefined> {
diff --git a/FrontEnd/src/views/center/CenterBoards.tsx b/FrontEnd/src/views/center/CenterBoards.tsx
index 431d1e9a..f5200415 100644
--- a/FrontEnd/src/views/center/CenterBoards.tsx
+++ b/FrontEnd/src/views/center/CenterBoards.tsx
@@ -5,9 +5,9 @@ import { useTranslation } from "react-i18next";
 import { pushAlert } from "@/services/alert";
 import { useUserLoggedIn } from "@/services/user";
 
-import { getHttpTimelineClient } from "http/timeline";
-import { getHttpBookmarkClient } from "http/bookmark";
-import { getHttpHighlightClient } from "http/highlight";
+import { getHttpTimelineClient } from "@/http/timeline";
+import { getHttpBookmarkClient } from "@/http/bookmark";
+import { getHttpHighlightClient } from "@/http/highlight";
 
 import TimelineBoard from "./TimelineBoard";
 
diff --git a/FrontEnd/src/views/center/TimelineBoard.tsx b/FrontEnd/src/views/center/TimelineBoard.tsx
index bb80266b..840c0415 100644
--- a/FrontEnd/src/views/center/TimelineBoard.tsx
+++ b/FrontEnd/src/views/center/TimelineBoard.tsx
@@ -4,7 +4,7 @@ import { Link } from "react-router-dom";
 import { useTranslation } from "react-i18next";
 import { Spinner } from "react-bootstrap";
 
-import { HttpTimelineInfo } from "http/timeline";
+import { HttpTimelineInfo } from "@/http/timeline";
 
 import TimelineLogo from "../common/TimelineLogo";
 import UserTimelineLogo from "../common/UserTimelineLogo";
@@ -34,7 +34,7 @@ const TimelineBoardItem: React.FC<TimelineBoardItemProps> = ({
   actions,
 }) => {
   const { name, title } = timeline;
-  const isPersonal = name.startsWith("@");
+  const isPersonal = name.startsWith("src");
   const url = isPersonal
     ? `/users/${timeline.owner.username}`
     : `/timelines/${name}`;
diff --git a/FrontEnd/src/views/center/TimelineCreateDialog.tsx b/FrontEnd/src/views/center/TimelineCreateDialog.tsx
index a2437ae5..b4e25ba1 100644
--- a/FrontEnd/src/views/center/TimelineCreateDialog.tsx
+++ b/FrontEnd/src/views/center/TimelineCreateDialog.tsx
@@ -3,7 +3,7 @@ import { useHistory } from "react-router";
 
 import { validateTimelineName } from "@/services/timeline";
 import OperationDialog from "../common/OperationDialog";
-import { getHttpTimelineClient, HttpTimelineInfo } from "http/timeline";
+import { getHttpTimelineClient, HttpTimelineInfo } from "@/http/timeline";
 
 interface TimelineCreateDialogProps {
   open: boolean;
diff --git a/FrontEnd/src/views/common/user/UserAvatar.tsx b/FrontEnd/src/views/common/user/UserAvatar.tsx
index 901697db..9e822528 100644
--- a/FrontEnd/src/views/common/user/UserAvatar.tsx
+++ b/FrontEnd/src/views/common/user/UserAvatar.tsx
@@ -1,6 +1,6 @@
 import React from "react";
 
-import { getHttpUserClient } from "http/user";
+import { getHttpUserClient } from "@/http/user";
 
 export interface UserAvatarProps
   extends React.ImgHTMLAttributes<HTMLImageElement> {
diff --git a/FrontEnd/src/views/home/TimelineListView.tsx b/FrontEnd/src/views/home/TimelineListView.tsx
index 975875af..2fb54820 100644
--- a/FrontEnd/src/views/home/TimelineListView.tsx
+++ b/FrontEnd/src/views/home/TimelineListView.tsx
@@ -2,7 +2,7 @@ import React from "react";
 
 import { convertI18nText, I18nText } from "@/common";
 
-import { HttpTimelineInfo } from "http/timeline";
+import { HttpTimelineInfo } from "@/http/timeline";
 import { useTranslation } from "react-i18next";
 import { Link } from "react-router-dom";
 
@@ -13,7 +13,7 @@ interface TimelineListItemProps {
 const TimelineListItem: React.FC<TimelineListItemProps> = ({ timeline }) => {
   const url = React.useMemo(
     () =>
-      timeline.name.startsWith("@")
+      timeline.name.startsWith("src")
         ? `/users/${timeline.owner.username}`
         : `/timelines/${timeline.name}`,
     [timeline]
diff --git a/FrontEnd/src/views/home/index.tsx b/FrontEnd/src/views/home/index.tsx
index efc364d7..0eca23ee 100644
--- a/FrontEnd/src/views/home/index.tsx
+++ b/FrontEnd/src/views/home/index.tsx
@@ -1,8 +1,8 @@
 import React from "react";
 import { useHistory } from "react-router";
 
-import { HttpTimelineInfo } from "http/timeline";
-import { getHttpHighlightClient } from "http/highlight";
+import { HttpTimelineInfo } from "@/http/timeline";
+import { getHttpHighlightClient } from "@/http/highlight";
 
 import SearchInput from "../common/SearchInput";
 import TimelineListView from "./TimelineListView";
diff --git a/FrontEnd/src/views/search/index.tsx b/FrontEnd/src/views/search/index.tsx
index 14a9709c..9a26802d 100644
--- a/FrontEnd/src/views/search/index.tsx
+++ b/FrontEnd/src/views/search/index.tsx
@@ -4,9 +4,9 @@ import { Container, Row } from "react-bootstrap";
 import { useHistory, useLocation } from "react-router";
 import { Link } from "react-router-dom";
 
-import { HttpNetworkError } from "http/common";
-import { getHttpSearchClient } from "http/search";
-import { HttpTimelineInfo } from "http/timeline";
+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";
@@ -14,7 +14,7 @@ import UserAvatar from "../common/user/UserAvatar";
 const TimelineSearchResultItemView: React.FC<{
   timeline: HttpTimelineInfo;
 }> = ({ timeline }) => {
-  const link = timeline.name.startsWith("@")
+  const link = timeline.name.startsWith("src")
     ? `users/${timeline.owner.username}`
     : `timelines/${timeline.name}`;
 
@@ -33,7 +33,7 @@ const TimelineSearchResultItemView: React.FC<{
         />
         {timeline.owner.nickname}
         <small className="ms-3 text-secondary">
-          @{timeline.owner.username}
+          src{timeline.owner.username}
         </small>
       </div>
     </div>
diff --git a/FrontEnd/src/views/settings/ChangeAvatarDialog.tsx b/FrontEnd/src/views/settings/ChangeAvatarDialog.tsx
index 338d2112..c4f6f492 100644
--- a/FrontEnd/src/views/settings/ChangeAvatarDialog.tsx
+++ b/FrontEnd/src/views/settings/ChangeAvatarDialog.tsx
@@ -7,7 +7,7 @@ import { UiLogicError } from "@/common";
 
 import { useUserLoggedIn } from "@/services/user";
 
-import { getHttpUserClient } from "http/user";
+import { getHttpUserClient } from "@/http/user";
 
 import ImageCropper, { Clip, applyClipToImage } from "../common/ImageCropper";
 
diff --git a/FrontEnd/src/views/settings/ChangeNicknameDialog.tsx b/FrontEnd/src/views/settings/ChangeNicknameDialog.tsx
index e6420f36..4b44cdd6 100644
--- a/FrontEnd/src/views/settings/ChangeNicknameDialog.tsx
+++ b/FrontEnd/src/views/settings/ChangeNicknameDialog.tsx
@@ -1,4 +1,4 @@
-import { getHttpUserClient } from "http/user";
+import { getHttpUserClient } from "@/http/user";
 import { useUserLoggedIn } from "@/services/user";
 import React from "react";
 
diff --git a/FrontEnd/src/views/timeline-common/ConnectionStatusBadge.tsx b/FrontEnd/src/views/timeline-common/ConnectionStatusBadge.tsx
index df43d8d2..1b9d6d2a 100644
--- a/FrontEnd/src/views/timeline-common/ConnectionStatusBadge.tsx
+++ b/FrontEnd/src/views/timeline-common/ConnectionStatusBadge.tsx
@@ -1,6 +1,6 @@
 import React from "react";
 import classnames from "classnames";
-import { HubConnectionState } from "@microsoft/signalr";
+import { HubConnectionState } from "srcmicrosoft/signalr";
 import { useTranslation } from "react-i18next";
 
 export interface ConnectionStatusBadgeProps {
diff --git a/FrontEnd/src/views/timeline-common/MarkdownPostEdit.tsx b/FrontEnd/src/views/timeline-common/MarkdownPostEdit.tsx
index 1514d28f..685e17be 100644
--- a/FrontEnd/src/views/timeline-common/MarkdownPostEdit.tsx
+++ b/FrontEnd/src/views/timeline-common/MarkdownPostEdit.tsx
@@ -4,7 +4,7 @@ import { Form, Spinner } from "react-bootstrap";
 import { useTranslation } from "react-i18next";
 import { Prompt } from "react-router";
 
-import { getHttpTimelineClient, HttpTimelinePostInfo } from "http/timeline";
+import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline";
 
 import FlatButton from "../common/FlatButton";
 import TabPages from "../common/TabPages";
diff --git a/FrontEnd/src/views/timeline-common/PostPropertyChangeDialog.tsx b/FrontEnd/src/views/timeline-common/PostPropertyChangeDialog.tsx
index 21c5272e..001e52d7 100644
--- a/FrontEnd/src/views/timeline-common/PostPropertyChangeDialog.tsx
+++ b/FrontEnd/src/views/timeline-common/PostPropertyChangeDialog.tsx
@@ -1,6 +1,6 @@
 import React from "react";
 
-import { getHttpTimelineClient, HttpTimelinePostInfo } from "http/timeline";
+import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline";
 
 import OperationDialog from "../common/OperationDialog";
 
diff --git a/FrontEnd/src/views/timeline-common/Timeline.tsx b/FrontEnd/src/views/timeline-common/Timeline.tsx
index 40619e64..31ea5870 100644
--- a/FrontEnd/src/views/timeline-common/Timeline.tsx
+++ b/FrontEnd/src/views/timeline-common/Timeline.tsx
@@ -1,12 +1,12 @@
 import React from "react";
-import { HubConnectionState } from "@microsoft/signalr";
+import { HubConnectionState } from "srcmicrosoft/signalr";
 
 import {
   HttpForbiddenError,
   HttpNetworkError,
   HttpNotFoundError,
-} from "http/common";
-import { getHttpTimelineClient, HttpTimelinePostInfo } from "http/timeline";
+} from "@/http/common";
+import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline";
 
 import { getTimelinePostUpdate$ } from "@/services/timeline";
 
diff --git a/FrontEnd/src/views/timeline-common/TimelineMember.tsx b/FrontEnd/src/views/timeline-common/TimelineMember.tsx
index 3d4de8b8..1ef085a7 100644
--- a/FrontEnd/src/views/timeline-common/TimelineMember.tsx
+++ b/FrontEnd/src/views/timeline-common/TimelineMember.tsx
@@ -4,12 +4,12 @@ import { Container, ListGroup, Modal, Row, Col, Button } from "react-bootstrap";
 
 import { convertI18nText, I18nText } from "@/common";
 
-import { HttpUser } from "http/user";
-import { getHttpSearchClient } from "http/search";
+import { HttpUser } from "@/http/user";
+import { getHttpSearchClient } from "@/http/search";
 
 import SearchInput from "../common/SearchInput";
 import UserAvatar from "../common/user/UserAvatar";
-import { getHttpTimelineClient, HttpTimelineInfo } from "http/timeline";
+import { getHttpTimelineClient, HttpTimelineInfo } from "@/http/timeline";
 
 const TimelineMemberItem: React.FC<{
   user: HttpUser;
@@ -27,7 +27,7 @@ const TimelineMemberItem: React.FC<{
         <Col>
           <Row>{user.nickname}</Row>
           <Row>
-            <small>{"@" + user.username}</small>
+            <small>{"src" + user.username}</small>
           </Row>
         </Col>
         {onAction ? (
diff --git a/FrontEnd/src/views/timeline-common/TimelinePageCardTemplate.tsx b/FrontEnd/src/views/timeline-common/TimelinePageCardTemplate.tsx
index 038ea3ab..623d643f 100644
--- a/FrontEnd/src/views/timeline-common/TimelinePageCardTemplate.tsx
+++ b/FrontEnd/src/views/timeline-common/TimelinePageCardTemplate.tsx
@@ -2,8 +2,8 @@ import React from "react";
 import classnames from "classnames";
 import { useTranslation } from "react-i18next";
 
-import { getHttpHighlightClient } from "http/highlight";
-import { getHttpBookmarkClient } from "http/bookmark";
+import { getHttpHighlightClient } from "@/http/highlight";
+import { getHttpBookmarkClient } from "@/http/bookmark";
 
 import { useUser } from "@/services/user";
 import { pushAlert } from "@/services/alert";
diff --git a/FrontEnd/src/views/timeline-common/TimelinePageTemplate.tsx b/FrontEnd/src/views/timeline-common/TimelinePageTemplate.tsx
index 44926cc6..658ce502 100644
--- a/FrontEnd/src/views/timeline-common/TimelinePageTemplate.tsx
+++ b/FrontEnd/src/views/timeline-common/TimelinePageTemplate.tsx
@@ -3,8 +3,8 @@ import { useTranslation } from "react-i18next";
 import { Container } from "react-bootstrap";
 import { HubConnectionState } from "@microsoft/signalr";
 
-import { HttpNetworkError, HttpNotFoundError } from "http/common";
-import { getHttpTimelineClient, HttpTimelineInfo } from "http/timeline";
+import { HttpNetworkError, HttpNotFoundError } from "@/http/common";
+import { getHttpTimelineClient, HttpTimelineInfo } from "@/http/timeline";
 
 import { getAlertHost } from "@/services/alert";
 
diff --git a/FrontEnd/src/views/timeline-common/TimelinePagedPostListView.tsx b/FrontEnd/src/views/timeline-common/TimelinePagedPostListView.tsx
index d569a2d7..37f02a82 100644
--- a/FrontEnd/src/views/timeline-common/TimelinePagedPostListView.tsx
+++ b/FrontEnd/src/views/timeline-common/TimelinePagedPostListView.tsx
@@ -1,6 +1,6 @@
 import React from "react";
 
-import { HttpTimelinePostInfo } from "http/timeline";
+import { HttpTimelinePostInfo } from "@/http/timeline";
 
 import useScrollToTop from "@/utilities/useScrollToTop";
 
diff --git a/FrontEnd/src/views/timeline-common/TimelinePostContentView.tsx b/FrontEnd/src/views/timeline-common/TimelinePostContentView.tsx
index f1b53335..607b72c9 100644
--- a/FrontEnd/src/views/timeline-common/TimelinePostContentView.tsx
+++ b/FrontEnd/src/views/timeline-common/TimelinePostContentView.tsx
@@ -4,8 +4,8 @@ import { Remarkable } from "remarkable";
 
 import { UiLogicError } from "@/common";
 
-import { HttpNetworkError } from "http/common";
-import { getHttpTimelineClient, HttpTimelinePostInfo } from "http/timeline";
+import { HttpNetworkError } from "@/http/common";
+import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline";
 
 import { useUser } from "@/services/user";
 
diff --git a/FrontEnd/src/views/timeline-common/TimelinePostEdit.tsx b/FrontEnd/src/views/timeline-common/TimelinePostEdit.tsx
index 0f470fd6..1f9f02a5 100644
--- a/FrontEnd/src/views/timeline-common/TimelinePostEdit.tsx
+++ b/FrontEnd/src/views/timeline-common/TimelinePostEdit.tsx
@@ -10,10 +10,10 @@ import {
   HttpTimelineInfo,
   HttpTimelinePostInfo,
   HttpTimelinePostPostRequestData,
-} from "http/timeline";
+} from "@/http/timeline";
 
 import { pushAlert } from "@/services/alert";
-import { base64 } from "http/common";
+import { base64 } from "@/http/common";
 
 import BlobImage from "../common/BlobImage";
 import LoadingButton from "../common/LoadingButton";
@@ -138,7 +138,7 @@ const TimelinePostEdit: React.FC<TimelinePostEditProps> = (props) => {
     (kind === "text" && text.length !== 0) ||
     (kind === "image" && image != null);
 
-  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+  // eslint-disable-next-line srctypescript-eslint/no-non-null-assertion
   const containerRef = React.useRef<HTMLDivElement>(null!);
 
   const notifyHeightChange = (): void => {
diff --git a/FrontEnd/src/views/timeline-common/TimelinePostListView.tsx b/FrontEnd/src/views/timeline-common/TimelinePostListView.tsx
index 49284720..ba204b72 100644
--- a/FrontEnd/src/views/timeline-common/TimelinePostListView.tsx
+++ b/FrontEnd/src/views/timeline-common/TimelinePostListView.tsx
@@ -1,7 +1,7 @@
 import React, { Fragment } from "react";
 import classnames from "classnames";
 
-import { HttpTimelinePostInfo } from "http/timeline";
+import { HttpTimelinePostInfo } from "@/http/timeline";
 
 import TimelinePostView from "./TimelinePostView";
 import TimelineDateLabel from "./TimelineDateLabel";
diff --git a/FrontEnd/src/views/timeline-common/TimelinePostView.tsx b/FrontEnd/src/views/timeline-common/TimelinePostView.tsx
index e8b32c71..f7b81478 100644
--- a/FrontEnd/src/views/timeline-common/TimelinePostView.tsx
+++ b/FrontEnd/src/views/timeline-common/TimelinePostView.tsx
@@ -3,7 +3,7 @@ import classnames from "classnames";
 import { Link } from "react-router-dom";
 import { useTranslation } from "react-i18next";
 
-import { getHttpTimelineClient, HttpTimelinePostInfo } from "http/timeline";
+import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline";
 
 import { pushAlert } from "@/services/alert";
 
diff --git a/FrontEnd/src/views/timeline-common/TimelinePropertyChangeDialog.tsx b/FrontEnd/src/views/timeline-common/TimelinePropertyChangeDialog.tsx
index 83b24d01..70f72025 100644
--- a/FrontEnd/src/views/timeline-common/TimelinePropertyChangeDialog.tsx
+++ b/FrontEnd/src/views/timeline-common/TimelinePropertyChangeDialog.tsx
@@ -6,7 +6,7 @@ import {
   HttpTimelinePatchRequest,
   kTimelineVisibilities,
   TimelineVisibility,
-} from "http/timeline";
+} from "@/http/timeline";
 
 import OperationDialog from "../common/OperationDialog";
 
diff --git a/FrontEnd/src/views/timeline/TimelineCard.tsx b/FrontEnd/src/views/timeline/TimelineCard.tsx
index e031b565..86063843 100644
--- a/FrontEnd/src/views/timeline/TimelineCard.tsx
+++ b/FrontEnd/src/views/timeline/TimelineCard.tsx
@@ -29,7 +29,7 @@ const TimelineCard: React.FC<TimelinePageCardProps> = (props) => {
               />
               {timeline.owner.nickname}
               <small className="ms-3 text-secondary">
-                @{timeline.owner.username}
+                src{timeline.owner.username}
               </small>
             </div>
           </>
diff --git a/FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx b/FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx
index 8821507d..dbca62ca 100644
--- a/FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx
+++ b/FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx
@@ -2,7 +2,7 @@ import React from "react";
 import { useHistory } from "react-router";
 import { Trans } from "react-i18next";
 
-import { getHttpTimelineClient, HttpTimelineInfo } from "http/timeline";
+import { getHttpTimelineClient, HttpTimelineInfo } from "@/http/timeline";
 
 import OperationDialog from "../common/OperationDialog";
 
diff --git a/FrontEnd/src/views/user/index.tsx b/FrontEnd/src/views/user/index.tsx
index 57454d0d..0013b254 100644
--- a/FrontEnd/src/views/user/index.tsx
+++ b/FrontEnd/src/views/user/index.tsx
@@ -14,7 +14,7 @@ const UserPage: React.FC = () => {
   return (
     <>
       <TimelinePageTemplate
-        timelineName={`@${username}`}
+        timelineName={`src${username}`}
         notFoundI18nKey="timeline.userNotExist"
         reloadKey={reloadKey}
         onReload={() => setReloadKey(reloadKey + 1)}
diff --git a/FrontEnd/tsconfig.json b/FrontEnd/tsconfig.json
new file mode 100644
index 00000000..3afe2c3e
--- /dev/null
+++ b/FrontEnd/tsconfig.json
@@ -0,0 +1,25 @@
+{
+  "compilerOptions": {
+    "target": "ESNext",
+    "lib": ["DOM", "DOM.Iterable", "ESNext"],
+    "allowJs": false,
+    "skipLibCheck": false,
+    "esModuleInterop": false,
+    "allowSyntheticDefaultImports": true,
+    "strict": true,
+    "forceConsistentCasingInFileNames": true,
+    "module": "ESNext",
+    "moduleResolution": "Node",
+    "resolveJsonModule": true,
+    "isolatedModules": true,
+    "jsx": "react",
+    "noEmit": true,
+    "types": ["vite/client"],
+    "sourceMap": true,
+    "baseUrl": "./",
+    "paths": {
+      "@/*": ["src/*"]
+    }
+  },
+  "include": ["./src"]
+}
diff --git a/FrontEnd/vite.config.js b/FrontEnd/vite.config.js
index 6e8cde1b..2e85c36a 100644
--- a/FrontEnd/vite.config.js
+++ b/FrontEnd/vite.config.js
@@ -7,6 +7,9 @@ import { defineConfig } from "vite";
 
 export default defineConfig({
   plugins: [reactRefresh()],
+  resolve: {
+    alias: [{ find: "@", replacement: "/src" }],
+  },
   server: {
     port: 13000,
     proxy: {
-- 
cgit v1.2.3


From 8c56e3fd388005bcb7aced75b73d7018511ceac8 Mon Sep 17 00:00:00 2001
From: crupest <crupest@outlook.com>
Date: Tue, 15 Jun 2021 16:41:03 +0800
Subject: ...

---
 FrontEnd/src/index.css                          | 53 -------------------------
 FrontEnd/src/palette.ts                         | 43 ++++++++++----------
 FrontEnd/src/views/admin/UserAdmin.tsx          | 20 +++++-----
 FrontEnd/src/views/center/TimelineBoard.tsx     |  3 --
 FrontEnd/src/views/common/button/FlatButton.tsx |  2 +-
 FrontEnd/src/views/common/button/TextButton.css | 36 +++++++++++++++++
 FrontEnd/src/views/common/button/TextButton.tsx | 36 +++++++++++++++++
 7 files changed, 105 insertions(+), 88 deletions(-)
 create mode 100644 FrontEnd/src/views/common/button/TextButton.css
 create mode 100644 FrontEnd/src/views/common/button/TextButton.tsx

(limited to 'FrontEnd/src/views/admin/UserAdmin.tsx')

diff --git a/FrontEnd/src/index.css b/FrontEnd/src/index.css
index ca0d4829..ff8c1866 100644
--- a/FrontEnd/src/index.css
+++ b/FrontEnd/src/index.css
@@ -81,59 +81,6 @@ textarea {
   color: #ffc107;
 }
 
-.text-button {
-  background: transparent;
-  border: none;
-}
-.text-button.primary {
-  color: #0d6efd;
-}
-.text-button.primary:hover {
-  color: #599bfe;
-}
-.text-button.secondary {
-  color: #6c757d;
-}
-.text-button.secondary:hover {
-  color: #939ba2;
-}
-.text-button.success {
-  color: #198754;
-}
-.text-button.success:hover {
-  color: #25c87c;
-}
-.text-button.info {
-  color: #0dcaf0;
-}
-.text-button.info:hover {
-  color: #54dbf6;
-}
-.text-button.warning {
-  color: #ffc107;
-}
-.text-button.warning:hover {
-  color: #ffd454;
-}
-.text-button.danger {
-  color: #dc3545;
-}
-.text-button.danger:hover {
-  color: #e77681;
-}
-.text-button.light {
-  color: #f8f9fa;
-}
-.text-button.light:hover {
-  color: white;
-}
-.text-button.dark {
-  color: #212529;
-}
-.text-button.dark:hover {
-  color: #434b53;
-}
-
 .touch-action-none {
   touch-action: none;
 }
diff --git a/FrontEnd/src/palette.ts b/FrontEnd/src/palette.ts
index 11b3de85..6385df66 100644
--- a/FrontEnd/src/palette.ts
+++ b/FrontEnd/src/palette.ts
@@ -19,18 +19,19 @@ export interface PaletteColor {
   [key: string]: string;
 }
 
-export interface Palette {
-  primary: PaletteColor;
-  primaryEnhance: PaletteColor;
-  secondary: PaletteColor;
-  textPrimary: PaletteColor;
-  textOnPrimary: PaletteColor;
-  danger: PaletteColor;
-  success: PaletteColor;
-  [key: string]: PaletteColor;
-}
+const paletteColorList = [
+  "primary",
+  "primary-enhance",
+  "secondary",
+  "text-primary",
+  "text-on-primary",
+  "danger",
+  "success",
+] as const;
+
+export type PaletteColorType = typeof paletteColorList[number];
 
-export type PaletteColorType = keyof Palette;
+export type Palette = Record<PaletteColorType, PaletteColor>;
 
 export function generatePaletteColor(color: string): PaletteColor {
   const c = Color(color);
@@ -60,26 +61,24 @@ export function generatePalette(options: {
 
   return {
     primary: generatePaletteColor(p.toString()),
-    primaryEnhance: generatePaletteColor(pe.toString()),
+    "primary-enhance": generatePaletteColor(pe.toString()),
     secondary: generatePaletteColor(s.toString()),
-    textPrimary: generatePaletteColor("#111111"),
-    textOnPrimary: generatePaletteColor(p.lightness() > 60 ? "black" : "white"),
+    "text-primary": generatePaletteColor("#111111"),
+    "text-on-primary": generatePaletteColor(
+      p.lightness() > 60 ? "black" : "white"
+    ),
     danger: generatePaletteColor("red"),
     success: generatePaletteColor("green"),
   };
 }
 
 export function generatePaletteCSS(palette: Palette): string {
-  function toSnakeCase(s: string): string {
-    return s.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
-  }
-
   const colors: [string, string][] = [];
-  for (const paletteColorName in palette) {
-    const paletteColor = palette[paletteColorName];
+  for (const colorType of paletteColorList) {
+    const paletteColor = palette[colorType];
     for (const variant in paletteColor) {
-      let key = `--tl-${toSnakeCase(paletteColorName)}`;
-      if (variant !== "color") key += `-${toSnakeCase(variant)}`;
+      let key = `--tl-${colorType}`;
+      if (variant !== "color") key += `-${variant}`;
       key += "-color";
       colors.push([key, paletteColor[variant]]);
     }
diff --git a/FrontEnd/src/views/admin/UserAdmin.tsx b/FrontEnd/src/views/admin/UserAdmin.tsx
index 558d3aee..eb141520 100644
--- a/FrontEnd/src/views/admin/UserAdmin.tsx
+++ b/FrontEnd/src/views/admin/UserAdmin.tsx
@@ -14,6 +14,7 @@ import {
   UserPermission,
 } from "@/http/user";
 import { Trans, useTranslation } from "react-i18next";
+import TextButton from "../common/button/TextButton";
 
 interface DialogProps<TData = undefined, TReturn = undefined> {
   open: boolean;
@@ -230,15 +231,16 @@ const UserItem: React.FC<UserItemProps> = ({ user, on }) => {
         className={classnames("edit-mask", !editMaskVisible && "d-none")}
         onClick={() => setEditMaskVisible(false)}
       >
-        <button className="text-button primary" onClick={on[kModify]}>
-          {t("admin:user.modify")}
-        </button>
-        <button className="text-button primary" onClick={on[kModifyPermission]}>
-          {t("admin:user.modifyPermissions")}
-        </button>
-        <button className="text-button danger" onClick={on[kDelete]}>
-          {t("admin:user.delete")}
-        </button>
+        <TextButton text="admin:user.modify" onClick={on[kModify]} />
+        <TextButton
+          text="admin:user.modifyPermissions"
+          onClick={on[kModifyPermission]}
+        />
+        <TextButton
+          text="admin:user.delete"
+          color="danger"
+          onClick={on[kDelete]}
+        />
       </div>
     </ListGroup.Item>
   );
diff --git a/FrontEnd/src/views/center/TimelineBoard.tsx b/FrontEnd/src/views/center/TimelineBoard.tsx
index a6a60b3d..e0a2d80e 100644
--- a/FrontEnd/src/views/center/TimelineBoard.tsx
+++ b/FrontEnd/src/views/center/TimelineBoard.tsx
@@ -1,7 +1,6 @@
 import React from "react";
 import classnames from "classnames";
 import { Link } from "react-router-dom";
-import { useTranslation } from "react-i18next";
 import { Spinner } from "react-bootstrap";
 
 import { HttpTimelineInfo } from "@/http/timeline";
@@ -220,8 +219,6 @@ interface TimelineBoardUIProps {
 const TimelineBoardUI: React.FC<TimelineBoardUIProps> = (props) => {
   const { title, timelines, className, editHandler } = props;
 
-  const { t } = useTranslation();
-
   const editable = editHandler != null;
 
   const [editing, setEditing] = React.useState<boolean>(false);
diff --git a/FrontEnd/src/views/common/button/FlatButton.tsx b/FrontEnd/src/views/common/button/FlatButton.tsx
index 0727eb88..24f47785 100644
--- a/FrontEnd/src/views/common/button/FlatButton.tsx
+++ b/FrontEnd/src/views/common/button/FlatButton.tsx
@@ -1,11 +1,11 @@
 import React from "react";
 import { useTranslation } from "react-i18next";
+import classNames from "classnames";
 
 import { convertI18nText, I18nText } from "@/common";
 import { PaletteColorType } from "@/palette";
 
 import "./FlatButton.css";
-import classNames from "classnames";
 
 function _FlatButton(
   {
diff --git a/FrontEnd/src/views/common/button/TextButton.css b/FrontEnd/src/views/common/button/TextButton.css
new file mode 100644
index 00000000..dc5abaaa
--- /dev/null
+++ b/FrontEnd/src/views/common/button/TextButton.css
@@ -0,0 +1,36 @@
+.cru-text-button {
+  background: transparent;
+  border: none;
+}
+
+.cru-text-button.primary {
+  color: var(--tl-primary-color);
+}
+
+.cru-text-button.primary:hover {
+  color: var(--tl-primary-lighter-color);
+}
+
+.cru-text-button.secondary {
+  color: var(--tl-secondary-color);
+}
+
+.cru-text-button.secondary:hover {
+  color: var(--tl-secondary-lighter-color);
+}
+
+.cru-text-button.success {
+  color: var(--tl-success-color);
+}
+
+.cru-text-button.success:hover {
+  color: var(--tl-success-lighter-color);
+}
+
+.cru-text-button.danger {
+  color: var(--tl-danger-color);
+}
+
+.cru-text-button.danger:hover {
+  color: var(--tl-danger-lighter-color);
+}
diff --git a/FrontEnd/src/views/common/button/TextButton.tsx b/FrontEnd/src/views/common/button/TextButton.tsx
new file mode 100644
index 00000000..2014158a
--- /dev/null
+++ b/FrontEnd/src/views/common/button/TextButton.tsx
@@ -0,0 +1,36 @@
+import React from "react";
+import { useTranslation } from "react-i18next";
+import classNames from "classnames";
+
+import { convertI18nText, I18nText } from "@/common";
+import { PaletteColorType } from "@/palette";
+
+import "./TextButton.css";
+
+function _TextButton(
+  {
+    text,
+    color,
+    onClick,
+  }: {
+    text: I18nText;
+    color?: PaletteColorType;
+    onClick?: () => void;
+  },
+  ref: React.ForwardedRef<HTMLButtonElement>
+): React.ReactElement | null {
+  const { t } = useTranslation();
+
+  return (
+    <button
+      ref={ref}
+      className={classNames("cru-text-button", color ?? "primary")}
+      onClick={onClick}
+    >
+      {convertI18nText(text, t)}
+    </button>
+  );
+}
+
+const TextButton = React.forwardRef(_TextButton);
+export default TextButton;
-- 
cgit v1.2.3

