From 29da3cef05ef138af2028ad44a27d706e9ae2df8 Mon Sep 17 00:00:00 2001
From: crupest <crupest@outlook.com>
Date: Sat, 26 Jun 2021 18:46:58 +0800
Subject: ...

---
 .../src/views/common/dailog/OperationDialog.tsx    | 472 +++++++++++++++++++++
 1 file changed, 472 insertions(+)
 create mode 100644 FrontEnd/src/views/common/dailog/OperationDialog.tsx

(limited to 'FrontEnd/src/views/common/dailog/OperationDialog.tsx')

diff --git a/FrontEnd/src/views/common/dailog/OperationDialog.tsx b/FrontEnd/src/views/common/dailog/OperationDialog.tsx
new file mode 100644
index 00000000..129e85d5
--- /dev/null
+++ b/FrontEnd/src/views/common/dailog/OperationDialog.tsx
@@ -0,0 +1,472 @@
+import React, { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { TwitterPicker } from "react-color";
+import moment from "moment";
+
+import { convertI18nText, I18nText, UiLogicError } from "@/common";
+
+import Button from "../button/Button";
+import LoadingButton from "../button/LoadingButton";
+import Dialog from "./Dialog";
+
+interface DefaultErrorPromptProps {
+  error?: string;
+}
+
+const DefaultErrorPrompt: React.FC<DefaultErrorPromptProps> = (props) => {
+  const { t } = useTranslation();
+
+  let result = <p className="text-danger">{t("operationDialog.error")}</p>;
+
+  if (props.error != null) {
+    result = (
+      <>
+        {result}
+        <p className="text-danger">{props.error}</p>
+      </>
+    );
+  }
+
+  return result;
+};
+
+export interface OperationDialogTextInput {
+  type: "text";
+  label?: I18nText;
+  password?: boolean;
+  initValue?: string;
+  textFieldProps?: Omit<
+    React.InputHTMLAttributes<HTMLInputElement>,
+    "type" | "value" | "onChange" | "aria-relevant"
+  >;
+  helperText?: string;
+}
+
+export interface OperationDialogBoolInput {
+  type: "bool";
+  label: I18nText;
+  initValue?: boolean;
+}
+
+export interface OperationDialogSelectInputOption {
+  value: string;
+  label: I18nText;
+  icon?: React.ReactElement;
+}
+
+export interface OperationDialogSelectInput {
+  type: "select";
+  label: I18nText;
+  options: OperationDialogSelectInputOption[];
+  initValue?: string;
+}
+
+export interface OperationDialogColorInput {
+  type: "color";
+  label?: I18nText;
+  initValue?: string | null;
+  canBeNull?: boolean;
+}
+
+export interface OperationDialogDateTimeInput {
+  type: "datetime";
+  label?: I18nText;
+  initValue?: string;
+}
+
+export type OperationDialogInput =
+  | OperationDialogTextInput
+  | OperationDialogBoolInput
+  | OperationDialogSelectInput
+  | OperationDialogColorInput
+  | OperationDialogDateTimeInput;
+
+interface OperationInputTypeStringToValueTypeMap {
+  text: string;
+  bool: boolean;
+  select: string;
+  color: string | null;
+  datetime: string;
+}
+
+type MapOperationInputTypeStringToValueType<Type> =
+  Type extends keyof OperationInputTypeStringToValueTypeMap
+    ? OperationInputTypeStringToValueTypeMap[Type]
+    : never;
+
+type MapOperationInputInfoValueType<T> = T extends OperationDialogInput
+  ? MapOperationInputTypeStringToValueType<T["type"]>
+  : T;
+
+const initValueMapperMap: {
+  [T in OperationDialogInput as T["type"]]: (
+    item: T
+  ) => MapOperationInputInfoValueType<T>;
+} = {
+  bool: (item) => item.initValue ?? false,
+  color: (item) => item.initValue ?? null,
+  datetime: (item) => {
+    if (item.initValue != null) {
+      return moment(item.initValue).format("YYYY-MM-DDTHH:mm:ss");
+    } else {
+      return "";
+    }
+  },
+  select: (item) => item.initValue ?? item.options[0].value,
+  text: (item) => item.initValue ?? "",
+};
+
+type MapOperationInputInfoValueTypeList<
+  Tuple extends readonly OperationDialogInput[]
+> = {
+  [Index in keyof Tuple]: MapOperationInputInfoValueType<Tuple[Index]>;
+} & { length: Tuple["length"] };
+
+export type OperationInputError =
+  | {
+      [index: number]: I18nText | null | undefined;
+    }
+  | null
+  | undefined;
+
+const isNoError = (error: OperationInputError): boolean => {
+  if (error == null) return true;
+  for (const key in error) {
+    if (error[key] != null) return false;
+  }
+  return true;
+};
+
+export interface OperationDialogProps<
+  TData,
+  OperationInputInfoList extends readonly OperationDialogInput[]
+> {
+  open: boolean;
+  onClose: () => void;
+  title: I18nText | (() => React.ReactNode);
+  themeColor?: "danger" | "success" | string;
+  onProcess: (
+    inputs: MapOperationInputInfoValueTypeList<OperationInputInfoList>
+  ) => Promise<TData>;
+  inputScheme?: OperationInputInfoList;
+  inputValidator?: (
+    inputs: MapOperationInputInfoValueTypeList<OperationInputInfoList>
+  ) => OperationInputError;
+  inputPrompt?: I18nText | (() => React.ReactNode);
+  processPrompt?: () => React.ReactNode;
+  successPrompt?: (data: TData) => React.ReactNode;
+  failurePrompt?: (error: unknown) => React.ReactNode;
+  onSuccessAndClose?: (data: TData) => void;
+}
+
+const OperationDialog = <
+  TData,
+  OperationInputInfoList extends readonly OperationDialogInput[]
+>(
+  props: OperationDialogProps<TData, OperationInputInfoList>
+): React.ReactElement => {
+  const inputScheme = (props.inputScheme ??
+    []) as readonly OperationDialogInput[];
+
+  const { t } = useTranslation();
+
+  type Step =
+    | { type: "input" }
+    | { type: "process" }
+    | {
+        type: "success";
+        data: TData;
+      }
+    | {
+        type: "failure";
+        data: unknown;
+      };
+  const [step, setStep] = useState<Step>({ type: "input" });
+
+  type ValueType = boolean | string | null | undefined;
+
+  const [values, setValues] = useState<ValueType[]>(
+    inputScheme.map((item) => {
+      if (item.type in initValueMapperMap) {
+        return (
+          initValueMapperMap[item.type] as (
+            i: OperationDialogInput
+          ) => ValueType
+        )(item);
+      } else {
+        throw new UiLogicError("Unknown input scheme.");
+      }
+    })
+  );
+  const [dirtyList, setDirtyList] = useState<boolean[]>(() =>
+    inputScheme.map(() => false)
+  );
+  const [inputError, setInputError] = useState<OperationInputError>();
+
+  const close = (): void => {
+    if (step.type !== "process") {
+      props.onClose();
+      if (step.type === "success" && props.onSuccessAndClose) {
+        props.onSuccessAndClose(step.data);
+      }
+    } else {
+      console.log("Attempt to close modal when processing.");
+    }
+  };
+
+  const onConfirm = (): void => {
+    setStep({ type: "process" });
+    props
+      .onProcess(
+        values.map((v, index) => {
+          if (inputScheme[index].type === "datetime" && v !== "")
+            return new Date(v as string).toISOString();
+          else return v;
+        }) as unknown as MapOperationInputInfoValueTypeList<OperationInputInfoList>
+      )
+      .then(
+        (d) => {
+          setStep({
+            type: "success",
+            data: d,
+          });
+        },
+        (e: unknown) => {
+          setStep({
+            type: "failure",
+            data: e,
+          });
+        }
+      );
+  };
+
+  let body: React.ReactNode;
+  if (step.type === "input" || step.type === "process") {
+    const process = step.type === "process";
+
+    let inputPrompt =
+      typeof props.inputPrompt === "function"
+        ? props.inputPrompt()
+        : convertI18nText(props.inputPrompt, t);
+    inputPrompt = <h6>{inputPrompt}</h6>;
+
+    const validate = (values: ValueType[]): boolean => {
+      const { inputValidator } = props;
+      if (inputValidator != null) {
+        const result = inputValidator(
+          values as unknown as MapOperationInputInfoValueTypeList<OperationInputInfoList>
+        );
+        setInputError(result);
+        return isNoError(result);
+      }
+      return true;
+    };
+
+    const updateValue = (index: number, newValue: ValueType): void => {
+      const oldValues = values;
+      const newValues = oldValues.slice();
+      newValues[index] = newValue;
+      setValues(newValues);
+      if (dirtyList[index] === false) {
+        const newDirtyList = dirtyList.slice();
+        newDirtyList[index] = true;
+        setDirtyList(newDirtyList);
+      }
+      validate(newValues);
+    };
+
+    const canProcess = isNoError(inputError);
+
+    body = (
+      <>
+        <div>
+          {inputPrompt}
+          {inputScheme.map((item, index) => {
+            const value = values[index];
+            const error: string | null =
+              dirtyList[index] && inputError != null
+                ? convertI18nText(inputError[index], t)
+                : null;
+
+            if (item.type === "text") {
+              return (
+                <Form.Group key={index}>
+                  {item.label && (
+                    <Form.Label>{convertI18nText(item.label, t)}</Form.Label>
+                  )}
+                  <Form.Control
+                    type={item.password === true ? "password" : "text"}
+                    value={value as string}
+                    onChange={(e) => {
+                      const v = e.target.value;
+                      updateValue(index, v);
+                    }}
+                    isInvalid={error != null}
+                    disabled={process}
+                  />
+                  {error != null && (
+                    <Form.Control.Feedback type="invalid">
+                      {error}
+                    </Form.Control.Feedback>
+                  )}
+                  {item.helperText && (
+                    <Form.Text>{t(item.helperText)}</Form.Text>
+                  )}
+                </Form.Group>
+              );
+            } else if (item.type === "bool") {
+              return (
+                <Form.Group key={index}>
+                  <Form.Check<"input">
+                    type="checkbox"
+                    checked={value as boolean}
+                    onChange={(event) => {
+                      updateValue(index, event.currentTarget.checked);
+                    }}
+                    label={convertI18nText(item.label, t)}
+                    disabled={process}
+                  />
+                </Form.Group>
+              );
+            } else if (item.type === "select") {
+              return (
+                <Form.Group key={index}>
+                  <Form.Label>{convertI18nText(item.label, t)}</Form.Label>
+                  <Form.Control
+                    as="select"
+                    value={value as string}
+                    onChange={(event) => {
+                      updateValue(index, event.target.value);
+                    }}
+                    disabled={process}
+                  >
+                    {item.options.map((option, i) => {
+                      return (
+                        <option value={option.value} key={i}>
+                          {option.icon}
+                          {convertI18nText(option.label, t)}
+                        </option>
+                      );
+                    })}
+                  </Form.Control>
+                </Form.Group>
+              );
+            } else if (item.type === "color") {
+              return (
+                <Form.Group key={index}>
+                  {item.canBeNull ? (
+                    <Form.Check<"input">
+                      type="checkbox"
+                      checked={value !== null}
+                      onChange={(event) => {
+                        if (event.currentTarget.checked) {
+                          updateValue(index, "#007bff");
+                        } else {
+                          updateValue(index, null);
+                        }
+                      }}
+                      label={convertI18nText(item.label, t)}
+                      disabled={process}
+                    />
+                  ) : (
+                    <Form.Label>{convertI18nText(item.label, t)}</Form.Label>
+                  )}
+                  {value !== null && (
+                    <TwitterPicker
+                      color={value as string}
+                      onChange={(result) => updateValue(index, result.hex)}
+                    />
+                  )}
+                </Form.Group>
+              );
+            } else if (item.type === "datetime") {
+              return (
+                <Form.Group key={index}>
+                  {item.label && (
+                    <Form.Label>{convertI18nText(item.label, t)}</Form.Label>
+                  )}
+                  <Form.Control
+                    type="datetime-local"
+                    value={value as string}
+                    onChange={(e) => {
+                      const v = e.target.value;
+                      updateValue(index, v);
+                    }}
+                    isInvalid={error != null}
+                    disabled={process}
+                  />
+                  {error != null && (
+                    <Form.Control.Feedback type="invalid">
+                      {error}
+                    </Form.Control.Feedback>
+                  )}
+                </Form.Group>
+              );
+            }
+          })}
+        </div>
+        <div>
+          <Button
+            text="operationDialog.cancel"
+            color="secondary"
+            onClick={close}
+          />
+          <LoadingButton
+            variant={props.themeColor}
+            loading={process}
+            disabled={!canProcess}
+            onClick={() => {
+              setDirtyList(inputScheme.map(() => true));
+              if (validate(values)) {
+                onConfirm();
+              }
+            }}
+          >
+            {t("operationDialog.confirm")}
+          </LoadingButton>
+        </div>
+      </>
+    );
+  } else {
+    let content: React.ReactNode;
+    const result = step;
+    if (result.type === "success") {
+      content =
+        props.successPrompt?.(result.data) ?? t("operationDialog.success");
+      if (typeof content === "string")
+        content = <p className="text-success">{content}</p>;
+    } else {
+      content = props.failurePrompt?.(result.data) ?? <DefaultErrorPrompt />;
+      if (typeof content === "string")
+        content = <DefaultErrorPrompt error={content} />;
+    }
+    body = (
+      <>
+        <div>{content}</div>
+        <div>
+          <Button text="operationDialog.ok" color="primary" onClick={close} />
+        </div>
+      </>
+    );
+  }
+
+  const title =
+    typeof props.title === "function"
+      ? props.title()
+      : convertI18nText(props.title, t);
+
+  return (
+    <Dialog open={props.open} onClose={close}>
+      <h3
+        className={
+          props.themeColor != null ? "text-" + props.themeColor : undefined
+        }
+      >
+        {title}
+      </h3>
+      {body}
+    </Dialog>
+  );
+};
+
+export default OperationDialog;
-- 
cgit v1.2.3


From a168336c0761b263ee5371218cbf6da236c0acce Mon Sep 17 00:00:00 2001
From: crupest <crupest@outlook.com>
Date: Sat, 26 Jun 2021 19:13:19 +0800
Subject: ...

---
 FrontEnd/src/views/admin/UserAdmin.tsx             |   9 +-
 FrontEnd/src/views/common/Menu.tsx                 |  26 ++---
 FrontEnd/src/views/common/SearchInput.tsx          |  13 +--
 FrontEnd/src/views/common/Spinner.tsx              |  13 +++
 FrontEnd/src/views/common/TabPages.tsx             |  20 +---
 FrontEnd/src/views/common/alert/AlertHost.tsx      |  11 +-
 FrontEnd/src/views/common/button/LoadingButton.tsx |  23 ++---
 .../src/views/common/dailog/OperationDialog.tsx    |  71 ++++++-------
 FrontEnd/src/views/login/index.tsx                 | 114 ++++++++++-----------
 .../src/views/timeline-common/MarkdownPostEdit.tsx |   8 +-
 .../src/views/timeline-common/TimelineMember.tsx   |  21 ++--
 .../views/timeline-common/TimelinePageTemplate.tsx |   1 -
 .../TimelinePostDeleteConfirmDialog.tsx            |  40 --------
 .../src/views/timeline-common/TimelinePostEdit.tsx |   1 -
 .../src/views/timeline-common/TimelinePostView.tsx |   6 +-
 15 files changed, 142 insertions(+), 235 deletions(-)
 create mode 100644 FrontEnd/src/views/common/Spinner.tsx
 delete mode 100644 FrontEnd/src/views/timeline-common/TimelinePostDeleteConfirmDialog.tsx

(limited to 'FrontEnd/src/views/common/dailog/OperationDialog.tsx')

diff --git a/FrontEnd/src/views/admin/UserAdmin.tsx b/FrontEnd/src/views/admin/UserAdmin.tsx
index 125219c3..481db1cc 100644
--- a/FrontEnd/src/views/admin/UserAdmin.tsx
+++ b/FrontEnd/src/views/admin/UserAdmin.tsx
@@ -15,6 +15,7 @@ import {
 import { Trans, useTranslation } from "react-i18next";
 import Button from "../common/button/Button";
 import TextButton from "../common/button/TextButton";
+import Spinner from "../common/Spinner";
 
 interface DialogProps<TData = undefined, TReturn = undefined> {
   open: boolean;
@@ -203,7 +204,7 @@ const UserItem: React.FC<UserItemProps> = ({ user, on }) => {
   const [editMaskVisible, setEditMaskVisible] = React.useState<boolean>(false);
 
   return (
-    <ListGroup.Item className="admin-user-item">
+    <div className="admin-user-item">
       <i
         className="bi-pencil-square float-end icon-button text-warning"
         onClick={() => setEditMaskVisible(true)}
@@ -242,7 +243,7 @@ const UserItem: React.FC<UserItemProps> = ({ user, on }) => {
           onClick={on[kDelete]}
         />
       </div>
-    </ListGroup.Item>
+    </div>
   );
 };
 
@@ -251,8 +252,6 @@ interface UserAdminProps {
 }
 
 const UserAdmin: React.FC<UserAdminProps> = () => {
-  const { t } = useTranslation();
-
   type DialogInfo =
     | null
     | {
@@ -390,7 +389,7 @@ const UserAdmin: React.FC<UserAdminProps> = () => {
       </>
     );
   } else {
-    return <Spinner animation="border" />;
+    return <Spinner />;
   }
 };
 
diff --git a/FrontEnd/src/views/common/Menu.tsx b/FrontEnd/src/views/common/Menu.tsx
index ae73a331..a5d2ec2c 100644
--- a/FrontEnd/src/views/common/Menu.tsx
+++ b/FrontEnd/src/views/common/Menu.tsx
@@ -1,9 +1,9 @@
 import React from "react";
 import classnames from "classnames";
-import { OverlayTrigger, OverlayTriggerProps, Popover } from "react-bootstrap";
 import { useTranslation } from "react-i18next";
 
-import { BootstrapThemeColor, convertI18nText, I18nText } from "@/common";
+import { convertI18nText, I18nText } from "@/common";
+import { PaletteColorType } from "@/palette";
 
 export type MenuItem =
   | {
@@ -13,7 +13,7 @@ export type MenuItem =
       type: "button";
       text: I18nText;
       iconClassName?: string;
-      color?: BootstrapThemeColor;
+      color?: PaletteColorType;
       onClick: () => void;
     };
 
@@ -67,26 +67,14 @@ export default Menu;
 
 export interface PopupMenuProps {
   items: MenuItems;
-  children: OverlayTriggerProps["children"];
+  children: React.ReactElement;
 }
 
 export const PopupMenu: React.FC<PopupMenuProps> = ({ items, children }) => {
   const [show, setShow] = React.useState<boolean>(false);
   const toggle = (): void => setShow(!show);
 
-  return (
-    <OverlayTrigger
-      trigger="click"
-      rootClose
-      overlay={
-        <Popover id="menu-popover">
-          <Menu items={items} onItemClicked={() => setShow(false)} />
-        </Popover>
-      }
-      show={show}
-      onToggle={toggle}
-    >
-      {children}
-    </OverlayTrigger>
-  );
+  // TODO:
+
+  return <Menu items={items} onItemClicked={() => setShow(false)} />;
 };
diff --git a/FrontEnd/src/views/common/SearchInput.tsx b/FrontEnd/src/views/common/SearchInput.tsx
index 79eb2732..fff11b95 100644
--- a/FrontEnd/src/views/common/SearchInput.tsx
+++ b/FrontEnd/src/views/common/SearchInput.tsx
@@ -1,7 +1,8 @@
 import React, { useCallback } from "react";
 import classnames from "classnames";
 import { useTranslation } from "react-i18next";
-import { Spinner, Form, Button } from "react-bootstrap";
+
+import LoadingButton from "./button/LoadingButton";
 
 export interface SearchInputProps {
   value: string;
@@ -64,13 +65,9 @@ const SearchInput: React.FC<SearchInputProps> = (props) => {
           "flex-shrink-0"
         )}
       >
-        {props.loading ? (
-          <Spinner variant="primary" animation="border" />
-        ) : (
-          <Button variant="outline-primary" onClick={props.onButtonClick}>
-            {props.buttonText ?? t("search")}
-          </Button>
-        )}
+        <LoadingButton loading={props.loading} onClick={props.onButtonClick}>
+          {props.buttonText ?? t("search")}
+        </LoadingButton>
       </div>
     </div>
   );
diff --git a/FrontEnd/src/views/common/Spinner.tsx b/FrontEnd/src/views/common/Spinner.tsx
new file mode 100644
index 00000000..783c9be2
--- /dev/null
+++ b/FrontEnd/src/views/common/Spinner.tsx
@@ -0,0 +1,13 @@
+import { PaletteColorType } from "@/palette";
+import React from "react";
+
+export interface SpinnerProps {
+  size?: "sm" | "md" | "lg" | number;
+  color?: PaletteColorType;
+}
+
+export default function Spinner(
+  props: SpinnerProps
+): React.ReactElement | null {
+  return <span />;
+}
diff --git a/FrontEnd/src/views/common/TabPages.tsx b/FrontEnd/src/views/common/TabPages.tsx
index 2b1d91cb..b7a9fb36 100644
--- a/FrontEnd/src/views/common/TabPages.tsx
+++ b/FrontEnd/src/views/common/TabPages.tsx
@@ -1,5 +1,4 @@
 import React from "react";
-import { Nav } from "react-bootstrap";
 import { useTranslation } from "react-i18next";
 
 import { convertI18nText, I18nText, UiLogicError } from "@/common";
@@ -31,6 +30,8 @@ const TabPages: React.FC<TabPagesProps> = ({
   pageContainerClassName,
   pageContainerStyle,
 }) => {
+  // TODO:
+
   if (pages.length === 0) {
     throw new UiLogicError("Page list can't be empty.");
   }
@@ -47,23 +48,6 @@ const TabPages: React.FC<TabPagesProps> = ({
 
   return (
     <div className={className} style={style}>
-      <Nav variant="tabs" className={navClassName} style={navStyle}>
-        {pages.map((page) => (
-          <Nav.Item key={page.id}>
-            <Nav.Link
-              active={tab === page.id}
-              onClick={() => {
-                setTab(page.id);
-              }}
-            >
-              {convertI18nText(page.tabText, t)}
-            </Nav.Link>
-          </Nav.Item>
-        ))}
-        {actions != null && (
-          <div className="ms-auto cru-tab-pages-action-area">{actions}</div>
-        )}
-      </Nav>
       <div className={pageContainerClassName} style={pageContainerStyle}>
         {currentPage.page}
       </div>
diff --git a/FrontEnd/src/views/common/alert/AlertHost.tsx b/FrontEnd/src/views/common/alert/AlertHost.tsx
index 949be7ed..21b9882d 100644
--- a/FrontEnd/src/views/common/alert/AlertHost.tsx
+++ b/FrontEnd/src/views/common/alert/AlertHost.tsx
@@ -1,7 +1,6 @@
 import React from "react";
 import without from "lodash/without";
 import { useTranslation } from "react-i18next";
-import { Alert } from "react-bootstrap";
 
 import {
   alertService,
@@ -52,13 +51,7 @@ export const AutoCloseAlert: React.FC<AutoCloseAlertProps> = (props) => {
   };
 
   return (
-    <Alert
-      className="m-3"
-      variant={alert.type ?? "primary"}
-      onClick={cancelTimer}
-      onClose={close}
-      dismissible
-    >
+    <div className="m-3" onClick={cancelTimer}>
       {(() => {
         const { message } = alert;
         if (typeof message === "function") {
@@ -66,7 +59,7 @@ export const AutoCloseAlert: React.FC<AutoCloseAlertProps> = (props) => {
           return <Message />;
         } else return convertI18nText(message, t);
       })()}
-    </Alert>
+    </div>
   );
 };
 
diff --git a/FrontEnd/src/views/common/button/LoadingButton.tsx b/FrontEnd/src/views/common/button/LoadingButton.tsx
index fd1c19b3..aee83aa2 100644
--- a/FrontEnd/src/views/common/button/LoadingButton.tsx
+++ b/FrontEnd/src/views/common/button/LoadingButton.tsx
@@ -1,26 +1,19 @@
 import React from "react";
 
-const LoadingButton: React.FC<{ loading?: boolean } & ButtonProps> = ({
+import { CommonButtonProps } from "./common";
+import Button from "./Button";
+import Spinner from "../Spinner";
+
+const LoadingButton: React.FC<{ loading?: boolean } & CommonButtonProps> = ({
   loading,
-  variant,
   disabled,
+  color,
   ...otherProps
 }) => {
   return (
-    <Button
-      variant={variant != null ? `outline-${variant}` : "outline-primary"}
-      disabled={disabled || loading}
-      {...otherProps}
-    >
+    <Button color={color} disabled={disabled || loading} {...otherProps}>
       {otherProps.children}
-      {loading ? (
-        <Spinner
-          className="ms-1"
-          variant={variant}
-          animation="grow"
-          size="sm"
-        />
-      ) : null}
+      {loading ? <Spinner color={color} /> : null}
     </Button>
   );
 };
diff --git a/FrontEnd/src/views/common/dailog/OperationDialog.tsx b/FrontEnd/src/views/common/dailog/OperationDialog.tsx
index 129e85d5..1e765276 100644
--- a/FrontEnd/src/views/common/dailog/OperationDialog.tsx
+++ b/FrontEnd/src/views/common/dailog/OperationDialog.tsx
@@ -5,6 +5,8 @@ import moment from "moment";
 
 import { convertI18nText, I18nText, UiLogicError } from "@/common";
 
+import { PaletteColorType } from "@/palette";
+
 import Button from "../button/Button";
 import LoadingButton from "../button/LoadingButton";
 import Dialog from "./Dialog";
@@ -144,7 +146,7 @@ export interface OperationDialogProps<
   open: boolean;
   onClose: () => void;
   title: I18nText | (() => React.ReactNode);
-  themeColor?: "danger" | "success" | string;
+  themeColor?: PaletteColorType;
   onProcess: (
     inputs: MapOperationInputInfoValueTypeList<OperationInputInfoList>
   ) => Promise<TData>;
@@ -290,50 +292,42 @@ const OperationDialog = <
 
             if (item.type === "text") {
               return (
-                <Form.Group key={index}>
+                <div key={index}>
                   {item.label && (
-                    <Form.Label>{convertI18nText(item.label, t)}</Form.Label>
+                    <label>{convertI18nText(item.label, t)}</label>
                   )}
-                  <Form.Control
+                  <input
                     type={item.password === true ? "password" : "text"}
                     value={value as string}
                     onChange={(e) => {
                       const v = e.target.value;
                       updateValue(index, v);
                     }}
-                    isInvalid={error != null}
                     disabled={process}
                   />
-                  {error != null && (
-                    <Form.Control.Feedback type="invalid">
-                      {error}
-                    </Form.Control.Feedback>
-                  )}
-                  {item.helperText && (
-                    <Form.Text>{t(item.helperText)}</Form.Text>
-                  )}
-                </Form.Group>
+                  {error != null && <div>{error}</div>}
+                  {item.helperText && <div>{t(item.helperText)}</div>}
+                </div>
               );
             } else if (item.type === "bool") {
               return (
-                <Form.Group key={index}>
-                  <Form.Check<"input">
+                <div key={index}>
+                  <input
                     type="checkbox"
                     checked={value as boolean}
                     onChange={(event) => {
                       updateValue(index, event.currentTarget.checked);
                     }}
-                    label={convertI18nText(item.label, t)}
                     disabled={process}
                   />
-                </Form.Group>
+                  <label>{convertI18nText(item.label, t)}</label>
+                </div>
               );
             } else if (item.type === "select") {
               return (
-                <Form.Group key={index}>
-                  <Form.Label>{convertI18nText(item.label, t)}</Form.Label>
-                  <Form.Control
-                    as="select"
+                <div key={index}>
+                  <label>{convertI18nText(item.label, t)}</label>
+                  <select
                     value={value as string}
                     onChange={(event) => {
                       updateValue(index, event.target.value);
@@ -348,14 +342,14 @@ const OperationDialog = <
                         </option>
                       );
                     })}
-                  </Form.Control>
-                </Form.Group>
+                  </select>
+                </div>
               );
             } else if (item.type === "color") {
               return (
-                <Form.Group key={index}>
+                <div key={index}>
                   {item.canBeNull ? (
-                    <Form.Check<"input">
+                    <input
                       type="checkbox"
                       checked={value !== null}
                       onChange={(event) => {
@@ -365,42 +359,35 @@ const OperationDialog = <
                           updateValue(index, null);
                         }
                       }}
-                      label={convertI18nText(item.label, t)}
                       disabled={process}
                     />
-                  ) : (
-                    <Form.Label>{convertI18nText(item.label, t)}</Form.Label>
-                  )}
+                  ) : null}
+                  <label>{convertI18nText(item.label, t)}</label>
                   {value !== null && (
                     <TwitterPicker
                       color={value as string}
                       onChange={(result) => updateValue(index, result.hex)}
                     />
                   )}
-                </Form.Group>
+                </div>
               );
             } else if (item.type === "datetime") {
               return (
-                <Form.Group key={index}>
+                <div key={index}>
                   {item.label && (
-                    <Form.Label>{convertI18nText(item.label, t)}</Form.Label>
+                    <label>{convertI18nText(item.label, t)}</label>
                   )}
-                  <Form.Control
+                  <input
                     type="datetime-local"
                     value={value as string}
                     onChange={(e) => {
                       const v = e.target.value;
                       updateValue(index, v);
                     }}
-                    isInvalid={error != null}
                     disabled={process}
                   />
-                  {error != null && (
-                    <Form.Control.Feedback type="invalid">
-                      {error}
-                    </Form.Control.Feedback>
-                  )}
-                </Form.Group>
+                  {error != null && <div>{error}</div>}
+                </div>
               );
             }
           })}
@@ -412,7 +399,7 @@ const OperationDialog = <
             onClick={close}
           />
           <LoadingButton
-            variant={props.themeColor}
+            color={props.themeColor}
             loading={process}
             disabled={!canProcess}
             onClick={() => {
diff --git a/FrontEnd/src/views/login/index.tsx b/FrontEnd/src/views/login/index.tsx
index 6c0aaf67..6d70c64a 100644
--- a/FrontEnd/src/views/login/index.tsx
+++ b/FrontEnd/src/views/login/index.tsx
@@ -81,67 +81,59 @@ const LoginPage: React.FC = (_) => {
     <div className="login-container container-fluid mt-2">
       <h1 className="text-center">{t("welcome")}</h1>
       <div>
-          <Form.Label htmlFor="username">{t("user.username")}</Form.Label>
-          <Form.Control
-            id="username"
-            disabled={process}
-            onChange={(e) => {
-              setUsername(e.target.value);
-              setUsernameDirty(true);
-            }}
-            value={username}
-            isInvalid={usernameDirty && username === ""}
-          />
-          {usernameDirty && username === "" && (
-            <Form.Control.Feedback type="invalid">
-              {t("login.emptyUsername")}
-            </Form.Control.Feedback>
-          )}
-          <Form.Label htmlFor="password">{t("user.password")}</Form.Label>
-          <Form.Control
-            id="password"
-            type="password"
-            disabled={process}
-            onChange={(e) => {
-              setPassword(e.target.value);
-              setPasswordDirty(true);
-            }}
-            value={password}
-            onKeyDown={onEnterPressInPassword}
-            isInvalid={passwordDirty && password === ""}
-          />
-          {passwordDirty && password === "" && (
-            <Form.Control.Feedback type="invalid">
-              {t("login.emptyPassword")}
-            </Form.Control.Feedback>
-          )}
-        </Form.Group>
-        <Form.Group>
-          <Form.Check<"input">
-            id="remember-me"
-            type="checkbox"
-            checked={rememberMe}
-            onChange={(e) => {
-              setRememberMe(e.currentTarget.checked);
-            }}
-            label={t("user.rememberMe")}
-          />
-        </Form.Group>
-        {error ? <p className="text-danger">{t(error)}</p> : null}
-        <div className="text-end">
-          <LoadingButton
-            loading={process}
-            variant="primary"
-            onClick={(e) => {
-              submit();
-              e.preventDefault();
-            }}
-            disabled={username === "" || password === "" ? true : undefined}
-          >
-            {t("user.login")}
-          </LoadingButton>
-        </div>
-      </Form>
+        <label htmlFor="username">{t("user.username")}</label>
+        <input
+          id="username"
+          disabled={process}
+          onChange={(e) => {
+            setUsername(e.target.value);
+            setUsernameDirty(true);
+          }}
+          value={username}
+        />
+        {usernameDirty && username === "" && (
+          <div>{t("login.emptyUsername")}</div>
+        )}
+        <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>{t("login.emptyPassword")}</div>
+        )}
+      </div>
+      <div>
+        <input
+          id="remember-me"
+          type="checkbox"
+          checked={rememberMe}
+          onChange={(e) => {
+            setRememberMe(e.currentTarget.checked);
+          }}
+        />
+        <label>{t("user.rememberMe")}</label>
+      </div>
+      {error ? <p className="text-danger">{t(error)}</p> : null}
+      <div className="text-end">
+        <LoadingButton
+          loading={process}
+          onClick={(e) => {
+            submit();
+            e.preventDefault();
+          }}
+          disabled={username === "" || password === "" ? true : undefined}
+        >
+          {t("user.login")}
+        </LoadingButton>
+      </div>
     </div>
   );
 };
diff --git a/FrontEnd/src/views/timeline-common/MarkdownPostEdit.tsx b/FrontEnd/src/views/timeline-common/MarkdownPostEdit.tsx
index 6cb64dd3..9f0753b9 100644
--- a/FrontEnd/src/views/timeline-common/MarkdownPostEdit.tsx
+++ b/FrontEnd/src/views/timeline-common/MarkdownPostEdit.tsx
@@ -1,15 +1,17 @@
+/* eslint-disable react/jsx-no-undef */
 import React from "react";
 import classnames from "classnames";
-import { Form, Spinner } from "react-bootstrap";
 import { useTranslation } from "react-i18next";
 import { Prompt } from "react-router";
 
 import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline";
 
+import TimelinePostBuilder from "@/services/TimelinePostBuilder";
+
 import FlatButton from "../common/button/FlatButton";
 import TabPages from "../common/TabPages";
-import TimelinePostBuilder from "@/services/TimelinePostBuilder";
 import ConfirmDialog from "../common/dailog/ConfirmDialog";
+import Spinner from "../common/Spinner";
 
 export interface MarkdownPostEditProps {
   timeline: string;
@@ -102,7 +104,7 @@ const MarkdownPostEdit: React.FC<MarkdownPostEditProps> = ({
         pageContainerClassName="py-2"
         actions={
           process ? (
-            <Spinner variant="primary" animation="border" size="sm" />
+            <Spinner />
           ) : (
             <>
               <FlatButton
diff --git a/FrontEnd/src/views/timeline-common/TimelineMember.tsx b/FrontEnd/src/views/timeline-common/TimelineMember.tsx
index 830ecd45..c8f26da7 100644
--- a/FrontEnd/src/views/timeline-common/TimelineMember.tsx
+++ b/FrontEnd/src/views/timeline-common/TimelineMember.tsx
@@ -5,21 +5,20 @@ import { convertI18nText, I18nText } from "@/common";
 
 import { HttpUser } from "@/http/user";
 import { getHttpSearchClient } from "@/http/search";
+import { getHttpTimelineClient, HttpTimelineInfo } from "@/http/timeline";
 
 import SearchInput from "../common/SearchInput";
 import UserAvatar from "../common/user/UserAvatar";
-import { getHttpTimelineClient, HttpTimelineInfo } from "@/http/timeline";
 import Button from "../common/button/Button";
+import Dialog from "../common/dailog/Dialog";
 
 const TimelineMemberItem: React.FC<{
   user: HttpUser;
   add?: boolean;
   onAction?: (username: string) => void;
 }> = ({ user, add, onAction }) => {
-  const { t } = useTranslation();
-
   return (
-    <ListGroup.Item className="container">
+    <div className="container">
       <div className="row">
         <div className="col col-auto">
           <UserAvatar username={user.username} className="avatar small" />
@@ -42,7 +41,7 @@ const TimelineMemberItem: React.FC<{
           </div>
         ) : null}
       </div>
-    </ListGroup.Item>
+    </div>
   );
 };
 
@@ -109,7 +108,7 @@ const TimelineMemberUserSearch: React.FC<{
             return <div>{t("timeline.member.noUserAvailableToAdd")}</div>;
           } else {
             return (
-              <ListGroup className="mt-2">
+              <div className="mt-2">
                 {users.map((user) => (
                   <TimelineMemberItem
                     key={user.username}
@@ -126,7 +125,7 @@ const TimelineMemberUserSearch: React.FC<{
                     }}
                   />
                 ))}
-              </ListGroup>
+              </div>
             );
           }
         } else if (userSearchState.type === "error") {
@@ -152,7 +151,7 @@ const TimelineMember: React.FC<TimelineMemberProps> = (props) => {
 
   return (
     <div className="container px-4 py-3">
-      <ListGroup>
+      <div>
         {members.map((member, index) => (
           <TimelineMemberItem
             key={member.username}
@@ -168,7 +167,7 @@ const TimelineMember: React.FC<TimelineMemberProps> = (props) => {
             }
           />
         ))}
-      </ListGroup>
+      </div>
       {timeline.manageable ? (
         <TimelineMemberUserSearch timeline={timeline} onChange={onChange} />
       ) : null}
@@ -187,8 +186,8 @@ export const TimelineMemberDialog: React.FC<TimelineMemberDialogProps> = (
   props
 ) => {
   return (
-    <Modal show centered onHide={props.onClose}>
+    <Dialog open onClose={props.onClose}>
       <TimelineMember {...props} />
-    </Modal>
+    </Dialog>
   );
 };
diff --git a/FrontEnd/src/views/timeline-common/TimelinePageTemplate.tsx b/FrontEnd/src/views/timeline-common/TimelinePageTemplate.tsx
index d05f18d4..ea6e8d40 100644
--- a/FrontEnd/src/views/timeline-common/TimelinePageTemplate.tsx
+++ b/FrontEnd/src/views/timeline-common/TimelinePageTemplate.tsx
@@ -1,5 +1,4 @@
 import React from "react";
-import { Container } from "react-bootstrap";
 import { HubConnectionState } from "@microsoft/signalr";
 
 import { HttpTimelineInfo } from "@/http/timeline";
diff --git a/FrontEnd/src/views/timeline-common/TimelinePostDeleteConfirmDialog.tsx b/FrontEnd/src/views/timeline-common/TimelinePostDeleteConfirmDialog.tsx
deleted file mode 100644
index e04bb7e1..00000000
--- a/FrontEnd/src/views/timeline-common/TimelinePostDeleteConfirmDialog.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-import React from "react";
-import { useTranslation } from "react-i18next";
-
-import Button from "../common/button/Button";
-
-const TimelinePostDeleteConfirmDialog: React.FC<{
-  onClose: () => void;
-  onConfirm: () => void;
-}> = ({ onClose, onConfirm }) => {
-  const { t } = useTranslation();
-
-  return (
-    <Modal onHide={onClose} show centered>
-      <Modal.Header>
-        <Modal.Title className="text-danger">
-          {t("timeline.post.deleteDialog.title")}
-        </Modal.Title>
-      </Modal.Header>
-      <Modal.Body>{t("timeline.post.deleteDialog.prompt")}</Modal.Body>
-      <Modal.Footer>
-        <Button
-          text="operationDialog.cancel"
-          color="secondary"
-          onClick={onClose}
-        />
-        <Button
-          variant="danger"
-          onClick={() => {
-            onConfirm();
-            onClose();
-          }}
-        >
-          {t("operationDialog.confirm")}
-        </Button>
-      </Modal.Footer>
-    </Modal>
-  );
-};
-
-export default TimelinePostDeleteConfirmDialog;
diff --git a/FrontEnd/src/views/timeline-common/TimelinePostEdit.tsx b/FrontEnd/src/views/timeline-common/TimelinePostEdit.tsx
index 5e59bee4..13aacb54 100644
--- a/FrontEnd/src/views/timeline-common/TimelinePostEdit.tsx
+++ b/FrontEnd/src/views/timeline-common/TimelinePostEdit.tsx
@@ -265,7 +265,6 @@ const TimelinePostEdit: React.FC<TimelinePostEditProps> = (props) => {
                 </PopupMenu>
               </div>
               <LoadingButton
-                variant="primary"
                 onClick={onSend}
                 disabled={!canSend}
                 loading={process}
diff --git a/FrontEnd/src/views/timeline-common/TimelinePostView.tsx b/FrontEnd/src/views/timeline-common/TimelinePostView.tsx
index 7b16e898..995c43df 100644
--- a/FrontEnd/src/views/timeline-common/TimelinePostView.tsx
+++ b/FrontEnd/src/views/timeline-common/TimelinePostView.tsx
@@ -9,9 +9,9 @@ import { pushAlert } from "@/services/alert";
 import UserAvatar from "../common/user/UserAvatar";
 import Card from "../common/Card";
 import FlatButton from "../common/button/FlatButton";
+import ConfirmDialog from "../common/dailog/ConfirmDialog";
 import TimelineLine from "./TimelineLine";
 import TimelinePostContentView from "./TimelinePostContentView";
-import TimelinePostDeleteConfirmDialog from "./TimelinePostDeleteConfirmDialog";
 import PostPropertyChangeDialog from "./PostPropertyChangeDialog";
 
 export interface TimelinePostViewProps {
@@ -116,7 +116,9 @@ const TimelinePostView: React.FC<TimelinePostViewProps> = (props) => {
         ) : null}
       </Card>
       {dialog === "delete" ? (
-        <TimelinePostDeleteConfirmDialog
+        <ConfirmDialog
+          title="timeline.post.deleteDialog.title"
+          body="timeline.post.deleteDialog.prompt"
           onClose={() => {
             setDialog(null);
             setOperationMaskVisible(false);
-- 
cgit v1.2.3


From 81e54efaae0ec1d5f1f90c3ca40448f8c61f0e80 Mon Sep 17 00:00:00 2001
From: crupest <crupest@outlook.com>
Date: Thu, 1 Jul 2021 16:43:59 +0800
Subject: ...

---
 FrontEnd/src/views/admin/UserAdmin.tsx                  | 12 ++++++------
 FrontEnd/src/views/center/TimelineBoard.tsx             |  6 +++---
 FrontEnd/src/views/common/button/Button.css             | 16 +++++++++-------
 FrontEnd/src/views/common/dailog/ConfirmDialog.tsx      |  7 +++++--
 FrontEnd/src/views/common/dailog/Dialog.css             |  9 +++++++++
 FrontEnd/src/views/common/dailog/OperationDialog.css    |  0
 FrontEnd/src/views/common/dailog/OperationDialog.tsx    | 16 +++++++++++-----
 FrontEnd/src/views/common/index.css                     |  3 ++-
 FrontEnd/src/views/common/menu/Menu.css                 |  2 +-
 FrontEnd/src/views/login/index.tsx                      |  2 +-
 FrontEnd/src/views/settings/index.tsx                   | 11 ++++++-----
 FrontEnd/src/views/timeline-common/TimelinePostView.tsx |  2 +-
 12 files changed, 54 insertions(+), 32 deletions(-)
 create mode 100644 FrontEnd/src/views/common/dailog/OperationDialog.css

(limited to 'FrontEnd/src/views/common/dailog/OperationDialog.tsx')

diff --git a/FrontEnd/src/views/admin/UserAdmin.tsx b/FrontEnd/src/views/admin/UserAdmin.tsx
index 481db1cc..6d2760f2 100644
--- a/FrontEnd/src/views/admin/UserAdmin.tsx
+++ b/FrontEnd/src/views/admin/UserAdmin.tsx
@@ -206,23 +206,23 @@ const UserItem: React.FC<UserItemProps> = ({ user, on }) => {
   return (
     <div className="admin-user-item">
       <i
-        className="bi-pencil-square float-end icon-button text-warning"
+        className="bi-pencil-square float-end icon-button cru-color-warning"
         onClick={() => setEditMaskVisible(true)}
       />
-      <h4 className="text-primary">{user.username}</h4>
-      <div className="text-secondary">
+      <h4 className="cru-color-primary">{user.username}</h4>
+      <div className="cru-color-secondary">
         {t("admin:user.nickname")}
         {user.nickname}
       </div>
-      <div className="text-secondary">
+      <div className="cru-color-secondary">
         {t("admin:user.uniqueId")}
         {user.uniqueId}
       </div>
-      <div className="text-secondary">
+      <div className="cru-color-secondary">
         {t("admin:user.permissions")}
         {user.permissions.map((permission) => {
           return (
-            <span key={permission} className="text-danger">
+            <span key={permission} className="cru-color-danger">
               {permission}{" "}
             </span>
           );
diff --git a/FrontEnd/src/views/center/TimelineBoard.tsx b/FrontEnd/src/views/center/TimelineBoard.tsx
index 3961a7bc..8c1f5fac 100644
--- a/FrontEnd/src/views/center/TimelineBoard.tsx
+++ b/FrontEnd/src/views/center/TimelineBoard.tsx
@@ -48,16 +48,16 @@ const TimelineBoardItem: React.FC<TimelineBoardItemProps> = ({
         <TimelineLogo className="icon" />
       )}
       <span className="title">{title}</span>
-      <small className="ms-2 text-secondary">{name}</small>
+      <small className="ms-2 cru-color-secondary">{name}</small>
       <span className="flex-grow-1"></span>
       {actions != null ? (
         <div className="right">
           <i
-            className="bi-trash icon-button text-danger px-2"
+            className="bi-trash icon-button cru-color-danger px-2"
             onClick={actions.onDelete}
           />
           <i
-            className="bi-grip-vertical icon-button text-gray px-2 touch-action-none"
+            className="bi-grip-vertical icon-button px-2 touch-action-none"
             onPointerDown={(e) => {
               e.currentTarget.setPointerCapture(e.pointerId);
               actions.onMove.start(e);
diff --git a/FrontEnd/src/views/common/button/Button.css b/FrontEnd/src/views/common/button/Button.css
index ec2dd798..54127f05 100644
--- a/FrontEnd/src/views/common/button/Button.css
+++ b/FrontEnd/src/views/common/button/Button.css
@@ -57,7 +57,8 @@
 }
 
 .cru-button:not(.outline):disabled {
-  background-color: var(--cru-button-f3-color);
+  background-color: var(--cru-disable-color);
+  cursor: auto;
 }
 
 .cru-button.outline {
@@ -67,23 +68,24 @@
   padding: 0.2em 0.5em;
   border-radius: 0.2em;
   transition: all 0.6s;
-  background-color: var(--cru-background-color);
+  background-color: white;
 }
 
 .cru-button.outline:hover {
   color: var(--cru-button-f1-color);
   border-color: var(--cru-button-f1-color);
-  background-color: var(--cru-background-1-color);
+  background-color: var(--cru-background-color);
 }
 
 .cru-button.outline:active {
   color: var(--cru-button-f2-color);
   border-color: var(--cru-button-f2-color);
-  background-color: var(--cru-background-2-color);
+  background-color: var(--cru-background-1-color);
 }
 
 .cru-button.outline:disabled {
-  color: var(--cru-button-f3-color);
-  border-color: var(--cru-button-f3-color);
-  background-color: var(--cru-background-3-color);
+  color: var(--cru-disable-color);
+  border-color: var(--cru-disable-color);
+  background-color: white;
+  cursor: auto;
 }
diff --git a/FrontEnd/src/views/common/dailog/ConfirmDialog.tsx b/FrontEnd/src/views/common/dailog/ConfirmDialog.tsx
index 1ad52350..c10b1cdb 100644
--- a/FrontEnd/src/views/common/dailog/ConfirmDialog.tsx
+++ b/FrontEnd/src/views/common/dailog/ConfirmDialog.tsx
@@ -16,12 +16,15 @@ const ConfirmDialog: React.FC<{
 
   return (
     <Dialog onClose={onClose} open={open}>
-      <h3 className="text-danger">{convertI18nText(title, t)}</h3>
+      <h3 className="cru-color-danger">{convertI18nText(title, t)}</h3>
+      <hr />
       <p>{convertI18nText(body, t)}</p>
-      <div>
+      <hr />
+      <div className="cru-dialog-bottom-area">
         <Button
           text="operationDialog.cancel"
           color="secondary"
+          outline
           onClick={onClose}
         />
         <Button
diff --git a/FrontEnd/src/views/common/dailog/Dialog.css b/FrontEnd/src/views/common/dailog/Dialog.css
index abce04fd..c1209d99 100644
--- a/FrontEnd/src/views/common/dailog/Dialog.css
+++ b/FrontEnd/src/views/common/dailog/Dialog.css
@@ -20,3 +20,12 @@
   padding: 1.5em;
   background-color: white;
 }
+
+.cru-dialog-bottom-area {
+  display: flex;
+  justify-content: flex-end;
+}
+
+.cru-dialog-bottom-area > * {
+  margin: 0 0.5em
+}
diff --git a/FrontEnd/src/views/common/dailog/OperationDialog.css b/FrontEnd/src/views/common/dailog/OperationDialog.css
new file mode 100644
index 00000000..e69de29b
diff --git a/FrontEnd/src/views/common/dailog/OperationDialog.tsx b/FrontEnd/src/views/common/dailog/OperationDialog.tsx
index 1e765276..f394a3d3 100644
--- a/FrontEnd/src/views/common/dailog/OperationDialog.tsx
+++ b/FrontEnd/src/views/common/dailog/OperationDialog.tsx
@@ -11,6 +11,8 @@ import Button from "../button/Button";
 import LoadingButton from "../button/LoadingButton";
 import Dialog from "./Dialog";
 
+import "./OperationDialog.css";
+
 interface DefaultErrorPromptProps {
   error?: string;
 }
@@ -18,13 +20,13 @@ interface DefaultErrorPromptProps {
 const DefaultErrorPrompt: React.FC<DefaultErrorPromptProps> = (props) => {
   const { t } = useTranslation();
 
-  let result = <p className="text-danger">{t("operationDialog.error")}</p>;
+  let result = <p className="cru-color-danger">{t("operationDialog.error")}</p>;
 
   if (props.error != null) {
     result = (
       <>
         {result}
-        <p className="text-danger">{props.error}</p>
+        <p className="cru-color-danger">{props.error}</p>
       </>
     );
   }
@@ -392,10 +394,12 @@ const OperationDialog = <
             }
           })}
         </div>
-        <div>
+        <hr />
+        <div className="cru-dialog-bottom-area">
           <Button
             text="operationDialog.cancel"
             color="secondary"
+            outline
             onClick={close}
           />
           <LoadingButton
@@ -421,7 +425,7 @@ const OperationDialog = <
       content =
         props.successPrompt?.(result.data) ?? t("operationDialog.success");
       if (typeof content === "string")
-        content = <p className="text-success">{content}</p>;
+        content = <p className="cru-color-success">{content}</p>;
     } else {
       content = props.failurePrompt?.(result.data) ?? <DefaultErrorPrompt />;
       if (typeof content === "string")
@@ -430,6 +434,7 @@ const OperationDialog = <
     body = (
       <>
         <div>{content}</div>
+        <hr />
         <div>
           <Button text="operationDialog.ok" color="primary" onClick={close} />
         </div>
@@ -446,11 +451,12 @@ const OperationDialog = <
     <Dialog open={props.open} onClose={close}>
       <h3
         className={
-          props.themeColor != null ? "text-" + props.themeColor : undefined
+          props.themeColor != null ? "cru-color-" + props.themeColor : undefined
         }
       >
         {title}
       </h3>
+      <hr />
       {body}
     </Dialog>
   );
diff --git a/FrontEnd/src/views/common/index.css b/FrontEnd/src/views/common/index.css
index 02eed6d9..62167cfc 100644
--- a/FrontEnd/src/views/common/index.css
+++ b/FrontEnd/src/views/common/index.css
@@ -2,7 +2,8 @@
   --cru-background-color: #f8f9fa;
   --cru-background-1-color: #e9ecef;
   --cru-background-2-color: #dee2e6;
-  --cru-background-3-color: #ced4da;
+
+  --cru-disable-color: #ced4da;
 
   --cru-primary-color: rgb(0, 123, 255);
   --cru-primary-l1-color: rgb(26, 136, 255);
diff --git a/FrontEnd/src/views/common/menu/Menu.css b/FrontEnd/src/views/common/menu/Menu.css
index c933b34f..a30eb5c6 100644
--- a/FrontEnd/src/views/common/menu/Menu.css
+++ b/FrontEnd/src/views/common/menu/Menu.css
@@ -22,7 +22,7 @@
 }
 
 .cru-menu-item.color-secondary:hover {
-  color: var(--cru-text-on-secondary-color);
+  color: var(--cru-secondary-t-color);
   background-color: var(--cru-secondary-color);
 }
 
diff --git a/FrontEnd/src/views/login/index.tsx b/FrontEnd/src/views/login/index.tsx
index 6d70c64a..782acdaa 100644
--- a/FrontEnd/src/views/login/index.tsx
+++ b/FrontEnd/src/views/login/index.tsx
@@ -122,7 +122,7 @@ const LoginPage: React.FC = (_) => {
         <label>{t("user.rememberMe")}</label>
       </div>
       {error ? <p className="text-danger">{t(error)}</p> : null}
-      <div className="text-end">
+      <div className="cru-text-end">
         <LoadingButton
           loading={process}
           onClick={(e) => {
diff --git a/FrontEnd/src/views/settings/index.tsx b/FrontEnd/src/views/settings/index.tsx
index f25911d7..69a74327 100644
--- a/FrontEnd/src/views/settings/index.tsx
+++ b/FrontEnd/src/views/settings/index.tsx
@@ -28,7 +28,7 @@ const SettingsPage: React.FC = (_) => {
       <div className="container">
         {user ? (
           <Card className="my-3 py-3">
-            <h3 className="px-3 mb-3 text-primary">
+            <h3 className="px-3 mb-3 cru-color-primary">
               {t("settings.subheaders.account")}
             </h3>
             <div
@@ -44,13 +44,13 @@ const SettingsPage: React.FC = (_) => {
               {t("settings.changeNickname")}
             </div>
             <div
-              className="settings-item clickable text-danger"
+              className="settings-item clickable cru-color-danger"
               onClick={() => setDialog("changepassword")}
             >
               {t("settings.changePassword")}
             </div>
             <div
-              className="settings-item clickable text-danger"
+              className="settings-item clickable cru-color-danger"
               onClick={() => {
                 setDialog("logout");
               }}
@@ -60,13 +60,13 @@ const SettingsPage: React.FC = (_) => {
           </Card>
         ) : null}
         <Card className="my-3 py-3">
-          <h3 className="px-3 mb-3 text-primary">
+          <h3 className="px-3 mb-3 cru-color-primary">
             {t("settings.subheaders.customization")}
           </h3>
           <div className="row settings-item first mx-0">
             <div className="col col-12 col-sm-auto">
               <div>{t("settings.languagePrimary")}</div>
-              <small className="d-block text-secondary">
+              <small className="d-block cru-color-secondary">
                 {t("settings.languageSecondary")}
               </small>
             </div>
@@ -94,6 +94,7 @@ const SettingsPage: React.FC = (_) => {
                 title="settings.dialogConfirmLogout.title"
                 body="settings.dialogConfirmLogout.prompt"
                 onClose={() => setDialog(null)}
+                open
                 onConfirm={() => {
                   void userService.logout().then(() => {
                     history.push("/");
diff --git a/FrontEnd/src/views/timeline-common/TimelinePostView.tsx b/FrontEnd/src/views/timeline-common/TimelinePostView.tsx
index 995c43df..652ff9c9 100644
--- a/FrontEnd/src/views/timeline-common/TimelinePostView.tsx
+++ b/FrontEnd/src/views/timeline-common/TimelinePostView.tsx
@@ -64,7 +64,7 @@ const TimelinePostView: React.FC<TimelinePostViewProps> = (props) => {
       >
         {post.editable ? (
           <i
-            className="bi-chevron-down icon-button primary-enhance float-end"
+            className="bi-chevron-down icon-button primary-enhance cru-float-right"
             onClick={(e) => {
               setOperationMaskVisible(true);
               e.stopPropagation();
-- 
cgit v1.2.3


From 672778ca5d9de5513c86d70394b2dd048639cdea Mon Sep 17 00:00:00 2001
From: crupest <crupest@outlook.com>
Date: Thu, 1 Jul 2021 17:16:20 +0800
Subject: ...

---
 FrontEnd/src/views/common/dailog/Dialog.css        |  1 +
 .../src/views/common/dailog/OperationDialog.css    | 26 ++++++++++++
 .../src/views/common/dailog/OperationDialog.tsx    | 49 ++++++++++++++++++----
 .../src/views/timeline/TimelineDeleteDialog.tsx    |  2 +-
 4 files changed, 68 insertions(+), 10 deletions(-)

(limited to 'FrontEnd/src/views/common/dailog/OperationDialog.tsx')

diff --git a/FrontEnd/src/views/common/dailog/Dialog.css b/FrontEnd/src/views/common/dailog/Dialog.css
index 67bfe2a9..22b420fc 100644
--- a/FrontEnd/src/views/common/dailog/Dialog.css
+++ b/FrontEnd/src/views/common/dailog/Dialog.css
@@ -15,6 +15,7 @@
 
 .cru-dialog-container {
   max-width: 100%;
+  min-width: 30vw;
 
   margin: auto;
 
diff --git a/FrontEnd/src/views/common/dailog/OperationDialog.css b/FrontEnd/src/views/common/dailog/OperationDialog.css
index e69de29b..26c3920b 100644
--- a/FrontEnd/src/views/common/dailog/OperationDialog.css
+++ b/FrontEnd/src/views/common/dailog/OperationDialog.css
@@ -0,0 +1,26 @@
+.cru-operation-dialog-group {
+  display: block;
+  margin: 0.4em 0;
+}
+
+.cru-operation-dialog-label {
+  display: block;
+  font-size: 0.8em;
+  color: var(--cru-primary-color);
+}
+
+.cru-operation-dialog-inline-label {
+  margin-inline-start: 0.5em;
+}
+
+.cru-operation-dialog-error-text {
+  display: block;
+  font-size: 0.8em;
+  color: var(--cru-danger-color);
+}
+
+.cru-operation-dialog-helper-text {
+  display: block;
+  font-size: 0.8em;
+  color: var(--cru-primary-color);
+}
diff --git a/FrontEnd/src/views/common/dailog/OperationDialog.tsx b/FrontEnd/src/views/common/dailog/OperationDialog.tsx
index f394a3d3..083d60da 100644
--- a/FrontEnd/src/views/common/dailog/OperationDialog.tsx
+++ b/FrontEnd/src/views/common/dailog/OperationDialog.tsx
@@ -12,6 +12,7 @@ import LoadingButton from "../button/LoadingButton";
 import Dialog from "./Dialog";
 
 import "./OperationDialog.css";
+import classNames from "classnames";
 
 interface DefaultErrorPromptProps {
   error?: string;
@@ -294,9 +295,17 @@ const OperationDialog = <
 
             if (item.type === "text") {
               return (
-                <div key={index}>
+                <div
+                  key={index}
+                  className={classNames(
+                    "cru-operation-dialog-group",
+                    error != null ? "error" : null
+                  )}
+                >
                   {item.label && (
-                    <label>{convertI18nText(item.label, t)}</label>
+                    <label className="cru-operation-dialog-label">
+                      {convertI18nText(item.label, t)}
+                    </label>
                   )}
                   <input
                     type={item.password === true ? "password" : "text"}
@@ -307,8 +316,16 @@ const OperationDialog = <
                     }}
                     disabled={process}
                   />
-                  {error != null && <div>{error}</div>}
-                  {item.helperText && <div>{t(item.helperText)}</div>}
+                  {error != null && (
+                    <div className="cru-operation-dialog-error-text">
+                      {error}
+                    </div>
+                  )}
+                  {item.helperText && (
+                    <div className="cru-operation-dialog-helper-text">
+                      {t(item.helperText)}
+                    </div>
+                  )}
                 </div>
               );
             } else if (item.type === "bool") {
@@ -327,8 +344,16 @@ const OperationDialog = <
               );
             } else if (item.type === "select") {
               return (
-                <div key={index}>
-                  <label>{convertI18nText(item.label, t)}</label>
+                <div
+                  key={index}
+                  className={classNames(
+                    "cru-operation-dialog-group",
+                    error != null ? "error" : null
+                  )}
+                >
+                  <label className="cru-operation-dialog-label">
+                    {convertI18nText(item.label, t)}
+                  </label>
                   <select
                     value={value as string}
                     onChange={(event) => {
@@ -364,10 +389,13 @@ const OperationDialog = <
                       disabled={process}
                     />
                   ) : null}
-                  <label>{convertI18nText(item.label, t)}</label>
+                  <label className="cru-operation-dialog-inline-label">
+                    {convertI18nText(item.label, t)}
+                  </label>
                   {value !== null && (
                     <TwitterPicker
                       color={value as string}
+                      triangle="hide"
                       onChange={(result) => updateValue(index, result.hex)}
                     />
                   )}
@@ -401,6 +429,7 @@ const OperationDialog = <
             color="secondary"
             outline
             onClick={close}
+            disabled={process}
           />
           <LoadingButton
             color={props.themeColor}
@@ -435,7 +464,7 @@ const OperationDialog = <
       <>
         <div>{content}</div>
         <hr />
-        <div>
+        <div className="cru-dialog-bottom-area">
           <Button text="operationDialog.ok" color="primary" onClick={close} />
         </div>
       </>
@@ -451,7 +480,9 @@ const OperationDialog = <
     <Dialog open={props.open} onClose={close}>
       <h3
         className={
-          props.themeColor != null ? "cru-color-" + props.themeColor : undefined
+          props.themeColor != null
+            ? "cru-color-" + props.themeColor
+            : "cru-color-primary"
         }
       >
         {title}
diff --git a/FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx b/FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx
index aedf4f29..68dedf86 100644
--- a/FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx
+++ b/FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx
@@ -26,7 +26,7 @@ const TimelineDeleteDialog: React.FC<TimelineDeleteDialog> = (props) => {
       inputPrompt={() => {
         return (
           <Trans i18nKey="timeline.deleteDialog.inputPrompt">
-            0<code className="mx-2">{{ name }}</code>2
+            0<code className="mx-2">{{ name: timeline.name }}</code>2
           </Trans>
         );
       }}
-- 
cgit v1.2.3


From a6f0f257f060c2e0be139a5e17a8f68cc9bfde4a Mon Sep 17 00:00:00 2001
From: crupest <crupest@outlook.com>
Date: Thu, 1 Jul 2021 20:02:32 +0800
Subject: ...

---
 FrontEnd/TODO.md                                   |  2 +-
 .../src/views/common/dailog/OperationDialog.tsx    | 44 +++++++++++++++++++---
 2 files changed, 40 insertions(+), 6 deletions(-)

(limited to 'FrontEnd/src/views/common/dailog/OperationDialog.tsx')

diff --git a/FrontEnd/TODO.md b/FrontEnd/TODO.md
index b407128e..53e9750e 100644
--- a/FrontEnd/TODO.md
+++ b/FrontEnd/TODO.md
@@ -6,5 +6,5 @@
 - [x] Improve buttons.
 - [x] Improve icon buttons.
 - [ ] Login page.
-- [ ] Alert.
+- [x] Alert.
 - [x] Move prefix `tl` to `cru`.
diff --git a/FrontEnd/src/views/common/dailog/OperationDialog.tsx b/FrontEnd/src/views/common/dailog/OperationDialog.tsx
index 083d60da..6bc846dd 100644
--- a/FrontEnd/src/views/common/dailog/OperationDialog.tsx
+++ b/FrontEnd/src/views/common/dailog/OperationDialog.tsx
@@ -51,6 +51,7 @@ export interface OperationDialogBoolInput {
   type: "bool";
   label: I18nText;
   initValue?: boolean;
+  helperText?: string;
 }
 
 export interface OperationDialogSelectInputOption {
@@ -77,6 +78,7 @@ export interface OperationDialogDateTimeInput {
   type: "datetime";
   label?: I18nText;
   initValue?: string;
+  helperText?: string;
 }
 
 export type OperationDialogInput =
@@ -330,7 +332,13 @@ const OperationDialog = <
               );
             } else if (item.type === "bool") {
               return (
-                <div key={index}>
+                <div
+                  key={index}
+                  className={classNames(
+                    "cru-operation-dialog-group",
+                    error != null ? "error" : null
+                  )}
+                >
                   <input
                     type="checkbox"
                     checked={value as boolean}
@@ -339,7 +347,19 @@ const OperationDialog = <
                     }}
                     disabled={process}
                   />
-                  <label>{convertI18nText(item.label, t)}</label>
+                  <label className="cru-operation-dialog-inline-label">
+                    {convertI18nText(item.label, t)}
+                  </label>
+                  {error != null && (
+                    <div className="cru-operation-dialog-error-text">
+                      {error}
+                    </div>
+                  )}
+                  {item.helperText && (
+                    <div className="cru-operation-dialog-helper-text">
+                      {t(item.helperText)}
+                    </div>
+                  )}
                 </div>
               );
             } else if (item.type === "select") {
@@ -374,7 +394,13 @@ const OperationDialog = <
               );
             } else if (item.type === "color") {
               return (
-                <div key={index}>
+                <div
+                  key={index}
+                  className={classNames(
+                    "cru-operation-dialog-group",
+                    error != null ? "error" : null
+                  )}
+                >
                   {item.canBeNull ? (
                     <input
                       type="checkbox"
@@ -403,9 +429,17 @@ const OperationDialog = <
               );
             } else if (item.type === "datetime") {
               return (
-                <div key={index}>
+                <div
+                  key={index}
+                  className={classNames(
+                    "cru-operation-dialog-group",
+                    error != null ? "error" : null
+                  )}
+                >
                   {item.label && (
-                    <label>{convertI18nText(item.label, t)}</label>
+                    <label className="cru-operation-dialog-label">
+                      {convertI18nText(item.label, t)}
+                    </label>
                   )}
                   <input
                     type="datetime-local"
-- 
cgit v1.2.3

