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

---
 FrontEnd/src/components/dialog/OperationDialog.tsx | 230 +++++++++++++++++++++
 1 file changed, 230 insertions(+)
 create mode 100644 FrontEnd/src/components/dialog/OperationDialog.tsx

(limited to 'FrontEnd/src/components/dialog/OperationDialog.tsx')

diff --git a/FrontEnd/src/components/dialog/OperationDialog.tsx b/FrontEnd/src/components/dialog/OperationDialog.tsx
new file mode 100644
index 00000000..e5db7f4f
--- /dev/null
+++ b/FrontEnd/src/components/dialog/OperationDialog.tsx
@@ -0,0 +1,230 @@
+import { useState, ReactNode, ComponentProps } from "react";
+import classNames from "classnames";
+
+import { useC, Text, ThemeColor } from "../common";
+
+import {
+  useInputs,
+  InputGroup,
+  Initializer as InputInitializer,
+  InputValueDict,
+  InputErrorDict,
+  InputConfirmValueDict,
+} from "../input";
+import Dialog from "./Dialog";
+import DialogContainer from "./DialogContainer";
+import { ButtonRow } from "../button";
+
+import "./OperationDialog.css";
+
+export type { InputInitializer, InputValueDict, InputErrorDict };
+
+interface OperationDialogPromptProps {
+  message?: Text;
+  customMessage?: Text;
+  customMessageNode?: ReactNode;
+  className?: string;
+}
+
+function OperationDialogPrompt(props: OperationDialogPromptProps) {
+  const { message, customMessage, customMessageNode, className } = props;
+
+  const c = useC();
+
+  return (
+    <div className={classNames(className, "cru-operation-dialog-prompt")}>
+      {message && <p>{c(message)}</p>}
+      {customMessageNode ?? (customMessage != null ? c(customMessage) : null)}
+    </div>
+  );
+}
+
+export interface OperationDialogProps<TData> {
+  open: boolean;
+  onClose: () => void;
+
+  color?: ThemeColor;
+  inputColor?: ThemeColor;
+  title: Text;
+  inputPrompt?: Text;
+  inputPromptNode?: ReactNode;
+  successPrompt?: (data: TData) => Text;
+  successPromptNode?: (data: TData) => ReactNode;
+  failurePrompt?: (error: unknown) => Text;
+  failurePromptNode?: (error: unknown) => ReactNode;
+
+  inputs: InputInitializer;
+
+  onProcess: (inputs: InputConfirmValueDict) => Promise<TData>;
+  onSuccessAndClose?: (data: TData) => void;
+}
+
+function OperationDialog<TData>(props: OperationDialogProps<TData>) {
+  const {
+    open,
+    onClose,
+    color,
+    inputColor,
+    title,
+    inputPrompt,
+    inputPromptNode,
+    successPrompt,
+    successPromptNode,
+    failurePrompt,
+    failurePromptNode,
+    inputs,
+    onProcess,
+    onSuccessAndClose,
+  } = props;
+
+  if (process.env.NODE_ENV === "development") {
+    if (inputPrompt && inputPromptNode) {
+      console.log("InputPrompt and inputPromptNode are both set.");
+    }
+    if (successPrompt && successPromptNode) {
+      console.log("SuccessPrompt and successPromptNode are both set.");
+    }
+    if (failurePrompt && failurePromptNode) {
+      console.log("FailurePrompt and failurePromptNode are both set.");
+    }
+  }
+
+  type Step =
+    | { type: "input" }
+    | { type: "process" }
+    | {
+        type: "success";
+        data: TData;
+      }
+    | {
+        type: "failure";
+        data: unknown;
+      };
+
+  const [step, setStep] = useState<Step>({ type: "input" });
+
+  const { inputGroupProps, hasErrorAndDirty, setAllDisabled, confirm } =
+    useInputs({
+      init: inputs,
+    });
+
+  function close() {
+    if (step.type !== "process") {
+      onClose();
+      if (step.type === "success" && onSuccessAndClose) {
+        onSuccessAndClose?.(step.data);
+      }
+    } else {
+      console.log("Attempt to close modal dialog when processing.");
+    }
+  }
+
+  function onConfirm() {
+    const result = confirm();
+    if (result.type === "ok") {
+      setStep({ type: "process" });
+      setAllDisabled(true);
+      onProcess(result.values).then(
+        (d) => {
+          setStep({
+            type: "success",
+            data: d,
+          });
+        },
+        (e: unknown) => {
+          setStep({
+            type: "failure",
+            data: e,
+          });
+        },
+      );
+    }
+  }
+
+  let body: ReactNode;
+  let buttons: ComponentProps<typeof ButtonRow>["buttons"];
+
+  if (step.type === "input" || step.type === "process") {
+    const isProcessing = step.type === "process";
+
+    body = (
+      <div>
+        <OperationDialogPrompt
+          customMessage={inputPrompt}
+          customMessageNode={inputPromptNode}
+        />
+        <InputGroup
+          containerClassName="cru-operation-dialog-input-group"
+          color={inputColor ?? "primary"}
+          {...inputGroupProps}
+        />
+      </div>
+    );
+    buttons = [
+      {
+        key: "cancel",
+        type: "normal",
+        props: {
+          text: "operationDialog.cancel",
+          color: "secondary",
+          outline: true,
+          onClick: close,
+          disabled: isProcessing,
+        },
+      },
+      {
+        key: "confirm",
+        type: "loading",
+        props: {
+          text: "operationDialog.confirm",
+          color,
+          loading: isProcessing,
+          disabled: hasErrorAndDirty,
+          onClick: onConfirm,
+        },
+      },
+    ];
+  } else {
+    const result = step;
+
+    const promptProps: OperationDialogPromptProps =
+      result.type === "success"
+        ? {
+            message: "operationDialog.success",
+            customMessage: successPrompt?.(result.data),
+            customMessageNode: successPromptNode?.(result.data),
+          }
+        : {
+            message: "operationDialog.error",
+            customMessage: failurePrompt?.(result.data),
+            customMessageNode: failurePromptNode?.(result.data),
+          };
+    body = (
+      <div>
+        <OperationDialogPrompt {...promptProps} />
+      </div>
+    );
+
+    buttons = [
+      {
+        key: "ok",
+        type: "normal",
+        props: {
+          text: "operationDialog.ok",
+          color: "primary",
+          onClick: close,
+        },
+      },
+    ];
+  }
+
+  return (
+    <Dialog open={open} onClose={close}>
+      <DialogContainer title={title} titleColor={color} buttons={buttons}>
+        {body}
+      </DialogContainer>
+    </Dialog>
+  );
+}
+
+export default OperationDialog;
-- 
cgit v1.2.3


From 256cc9592a3f31fc392e1ccdb699aa206b7b47ce Mon Sep 17 00:00:00 2001
From: crupest <crupest@outlook.com>
Date: Sat, 26 Aug 2023 23:49:28 +0800
Subject: ...

---
 FrontEnd/src/common.ts                             |  2 -
 FrontEnd/src/components/AppBar.tsx                 |  3 +-
 FrontEnd/src/components/Card.tsx                   |  1 +
 FrontEnd/src/components/LoadFailReload.tsx         | 37 ------------------
 FrontEnd/src/components/LoadingPage.tsx            | 13 -------
 FrontEnd/src/components/SearchInput.tsx            |  2 +-
 FrontEnd/src/components/button/ButtonRowV2.tsx     |  3 +-
 FrontEnd/src/components/button/LoadingButton.tsx   |  1 -
 FrontEnd/src/components/common.ts                  |  1 -
 FrontEnd/src/components/dialog/ConfirmDialog.css   |  0
 FrontEnd/src/components/dialog/ConfirmDialog.tsx   |  1 -
 FrontEnd/src/components/dialog/OperationDialog.tsx |  7 +---
 FrontEnd/src/components/dialog/index.ts            |  1 +
 FrontEnd/src/components/hooks.ts                   | 14 -------
 FrontEnd/src/components/hooks/index.ts             |  3 ++
 FrontEnd/src/components/hooks/responsive.ts        |  7 ++++
 FrontEnd/src/components/hooks/useClickOutside.ts   | 38 ++++++++++++++++++
 FrontEnd/src/components/hooks/useScrollToBottom.ts | 44 +++++++++++++++++++++
 FrontEnd/src/components/input/InputGroup.tsx       | 16 ++++----
 FrontEnd/src/components/menu/Menu.tsx              |  2 +-
 FrontEnd/src/components/menu/PopupMenu.tsx         |  6 +--
 FrontEnd/src/pages/login/index.tsx                 | 14 ++-----
 FrontEnd/src/pages/register/index.tsx              | 26 +++++--------
 FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx  |  5 +--
 .../src/pages/setting/ChangePasswordDialog.tsx     | 17 ++++----
 FrontEnd/src/pages/setting/index.tsx               | 11 +++---
 FrontEnd/src/pages/timeline/Timeline.tsx           |  3 +-
 .../src/pages/timeline/TimelineDeleteDialog.tsx    |  4 +-
 FrontEnd/src/pages/timeline/TimelinePostView.tsx   |  8 ++--
 FrontEnd/src/utilities/hooks.ts                    |  5 ---
 FrontEnd/src/utilities/hooks/mediaQuery.ts         |  5 ---
 FrontEnd/src/utilities/hooks/useClickOutside.ts    | 38 ------------------
 FrontEnd/src/utilities/hooks/useScrollToBottom.ts  | 45 ----------------------
 33 files changed, 149 insertions(+), 234 deletions(-)
 delete mode 100644 FrontEnd/src/components/LoadFailReload.tsx
 delete mode 100644 FrontEnd/src/components/LoadingPage.tsx
 delete mode 100644 FrontEnd/src/components/dialog/ConfirmDialog.css
 delete mode 100644 FrontEnd/src/components/hooks.ts
 create mode 100644 FrontEnd/src/components/hooks/index.ts
 create mode 100644 FrontEnd/src/components/hooks/responsive.ts
 create mode 100644 FrontEnd/src/components/hooks/useClickOutside.ts
 create mode 100644 FrontEnd/src/components/hooks/useScrollToBottom.ts
 delete mode 100644 FrontEnd/src/utilities/hooks.ts
 delete mode 100644 FrontEnd/src/utilities/hooks/mediaQuery.ts
 delete mode 100644 FrontEnd/src/utilities/hooks/useClickOutside.ts
 delete mode 100644 FrontEnd/src/utilities/hooks/useScrollToBottom.ts

(limited to 'FrontEnd/src/components/dialog/OperationDialog.tsx')

diff --git a/FrontEnd/src/common.ts b/FrontEnd/src/common.ts
index 7c053140..1ca796c3 100644
--- a/FrontEnd/src/common.ts
+++ b/FrontEnd/src/common.ts
@@ -3,8 +3,6 @@
 // This error should never occur. If it does, it indicates there is some logic bug in codes.
 export class UiLogicError extends Error {}
 
-export const highlightTimelineUsername = "crupest";
-
 export type { I18nText } from "./i18n";
 export type { I18nText as Text } from "./i18n";
 export { c, convertI18nText } from "./i18n";
diff --git a/FrontEnd/src/components/AppBar.tsx b/FrontEnd/src/components/AppBar.tsx
index da3a946f..1a5c1941 100644
--- a/FrontEnd/src/components/AppBar.tsx
+++ b/FrontEnd/src/components/AppBar.tsx
@@ -2,9 +2,10 @@ import { useState } from "react";
 import classnames from "classnames";
 import { Link, NavLink } from "react-router-dom";
 
-import { I18nText, useC, useMobile } from "./common";
 import { useUser } from "~src/services/user";
 
+import { I18nText, useC } from "./common";
+import { useMobile } from "./hooks";
 import TimelineLogo from "./TimelineLogo";
 import { IconButton } from "./button";
 import UserAvatar from "./user/UserAvatar";
diff --git a/FrontEnd/src/components/Card.tsx b/FrontEnd/src/components/Card.tsx
index a8f0d3cc..5d3ef630 100644
--- a/FrontEnd/src/components/Card.tsx
+++ b/FrontEnd/src/components/Card.tsx
@@ -2,6 +2,7 @@ import { ComponentPropsWithoutRef, Ref } from "react";
 import classNames from "classnames";
 
 import { ThemeColor } from "./common";
+
 import "./Card.css";
 
 interface CardProps extends ComponentPropsWithoutRef<"div"> {
diff --git a/FrontEnd/src/components/LoadFailReload.tsx b/FrontEnd/src/components/LoadFailReload.tsx
deleted file mode 100644
index 81ba1f67..00000000
--- a/FrontEnd/src/components/LoadFailReload.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-import * as React from "react";
-import { Trans } from "react-i18next";
-
-export interface LoadFailReloadProps {
-  className?: string;
-  style?: React.CSSProperties;
-  onReload: () => void;
-}
-
-const LoadFailReload: React.FC<LoadFailReloadProps> = ({
-  onReload,
-  className,
-  style,
-}) => {
-  return (
-    <Trans
-      i18nKey="loadFailReload"
-      parent="div"
-      className={className}
-      style={style}
-    >
-      0
-      <a
-        href="#"
-        onClick={(e) => {
-          onReload();
-          e.preventDefault();
-        }}
-      >
-        1
-      </a>
-      2
-    </Trans>
-  );
-};
-
-export default LoadFailReload;
diff --git a/FrontEnd/src/components/LoadingPage.tsx b/FrontEnd/src/components/LoadingPage.tsx
deleted file mode 100644
index 35ee1aa8..00000000
--- a/FrontEnd/src/components/LoadingPage.tsx
+++ /dev/null
@@ -1,13 +0,0 @@
-import * as React from "react";
-
-import Spinner from "./Spinner";
-
-const LoadingPage: React.FC = () => {
-  return (
-    <div className="position-fixed w-100 h-100 d-flex justify-content-center align-items-center">
-      <Spinner />
-    </div>
-  );
-};
-
-export default LoadingPage;
diff --git a/FrontEnd/src/components/SearchInput.tsx b/FrontEnd/src/components/SearchInput.tsx
index e3216b86..71820bfa 100644
--- a/FrontEnd/src/components/SearchInput.tsx
+++ b/FrontEnd/src/components/SearchInput.tsx
@@ -1,7 +1,7 @@
 import classNames from "classnames";
 
 import { useC, Text } from "./common";
-import LoadingButton from "./button/LoadingButton";
+import { LoadingButton } from "./button";
 
 import "./SearchInput.css";
 
diff --git a/FrontEnd/src/components/button/ButtonRowV2.tsx b/FrontEnd/src/components/button/ButtonRowV2.tsx
index 3467ad52..5129e7f1 100644
--- a/FrontEnd/src/components/button/ButtonRowV2.tsx
+++ b/FrontEnd/src/components/button/ButtonRowV2.tsx
@@ -1,13 +1,14 @@
 import { ComponentPropsWithoutRef, Ref } from "react";
 import classNames from "classnames";
 
+import { Text, ThemeColor } from "../common";
+
 import Button from "./Button";
 import FlatButton from "./FlatButton";
 import IconButton from "./IconButton";
 import LoadingButton from "./LoadingButton";
 
 import "./ButtonRow.css";
-import { Text, ThemeColor } from "../common";
 
 interface ButtonRowV2ButtonBase {
   key: string | number;
diff --git a/FrontEnd/src/components/button/LoadingButton.tsx b/FrontEnd/src/components/button/LoadingButton.tsx
index 7e7d08e6..d9d41ddb 100644
--- a/FrontEnd/src/components/button/LoadingButton.tsx
+++ b/FrontEnd/src/components/button/LoadingButton.tsx
@@ -1,7 +1,6 @@
 import classNames from "classnames";
 
 import { I18nText, ThemeColor, useC } from "../common";
-
 import Spinner from "../Spinner";
 
 import "./LoadingButton.css";
diff --git a/FrontEnd/src/components/common.ts b/FrontEnd/src/components/common.ts
index e6f7319f..b96388ab 100644
--- a/FrontEnd/src/components/common.ts
+++ b/FrontEnd/src/components/common.ts
@@ -11,4 +11,3 @@ export const themeColors = [
 export type ThemeColor = (typeof themeColors)[number];
 
 export { breakpoints } from "./breakpoints";
-export { useMobile } from "./hooks";
diff --git a/FrontEnd/src/components/dialog/ConfirmDialog.css b/FrontEnd/src/components/dialog/ConfirmDialog.css
deleted file mode 100644
index e69de29b..00000000
diff --git a/FrontEnd/src/components/dialog/ConfirmDialog.tsx b/FrontEnd/src/components/dialog/ConfirmDialog.tsx
index 26939c9b..1d997305 100644
--- a/FrontEnd/src/components/dialog/ConfirmDialog.tsx
+++ b/FrontEnd/src/components/dialog/ConfirmDialog.tsx
@@ -1,5 +1,4 @@
 import { useC, Text, ThemeColor } from "../common";
-
 import Dialog from "./Dialog";
 import DialogContainer from "./DialogContainer";
 
diff --git a/FrontEnd/src/components/dialog/OperationDialog.tsx b/FrontEnd/src/components/dialog/OperationDialog.tsx
index e5db7f4f..96766825 100644
--- a/FrontEnd/src/components/dialog/OperationDialog.tsx
+++ b/FrontEnd/src/components/dialog/OperationDialog.tsx
@@ -2,23 +2,18 @@ import { useState, ReactNode, ComponentProps } from "react";
 import classNames from "classnames";
 
 import { useC, Text, ThemeColor } from "../common";
-
 import {
   useInputs,
   InputGroup,
   Initializer as InputInitializer,
-  InputValueDict,
-  InputErrorDict,
   InputConfirmValueDict,
 } from "../input";
+import { ButtonRow } from "../button";
 import Dialog from "./Dialog";
 import DialogContainer from "./DialogContainer";
-import { ButtonRow } from "../button";
 
 import "./OperationDialog.css";
 
-export type { InputInitializer, InputValueDict, InputErrorDict };
-
 interface OperationDialogPromptProps {
   message?: Text;
   customMessage?: Text;
diff --git a/FrontEnd/src/components/dialog/index.ts b/FrontEnd/src/components/dialog/index.ts
index 59f15791..17db8fd0 100644
--- a/FrontEnd/src/components/dialog/index.ts
+++ b/FrontEnd/src/components/dialog/index.ts
@@ -4,6 +4,7 @@ export { default as Dialog } from "./Dialog";
 export { default as FullPageDialog } from "./FullPageDialog";
 export { default as OperationDialog } from "./OperationDialog";
 export { default as ConfirmDialog } from "./ConfirmDialog";
+export { default as DialogContainer } from "./DialogContainer";
 
 type DialogMap<D extends string, V> = {
   [K in D]: V;
diff --git a/FrontEnd/src/components/hooks.ts b/FrontEnd/src/components/hooks.ts
deleted file mode 100644
index 523a4538..00000000
--- a/FrontEnd/src/components/hooks.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-// TODO: Migrate hooks
-
-export {
-  useIsSmallScreen,
-  useClickOutside,
-  useScrollToBottom,
-} from "~src/utilities/hooks";
-
-import { useMediaQuery } from "react-responsive";
-import { breakpoints } from "./breakpoints";
-
-export function useMobile(): boolean {
-  return useMediaQuery({ maxWidth: breakpoints.sm });
-}
diff --git a/FrontEnd/src/components/hooks/index.ts b/FrontEnd/src/components/hooks/index.ts
new file mode 100644
index 00000000..3c9859bc
--- /dev/null
+++ b/FrontEnd/src/components/hooks/index.ts
@@ -0,0 +1,3 @@
+export { useMobile } from "./responsive";
+export { default as useClickOutside } from "./useClickOutside";
+export { default as useScrollToBottom } from "./useScrollToBottom";
diff --git a/FrontEnd/src/components/hooks/responsive.ts b/FrontEnd/src/components/hooks/responsive.ts
new file mode 100644
index 00000000..6bcce96c
--- /dev/null
+++ b/FrontEnd/src/components/hooks/responsive.ts
@@ -0,0 +1,7 @@
+import { useMediaQuery } from "react-responsive";
+
+import { breakpoints } from "../breakpoints";
+
+export function useMobile(): boolean {
+  return useMediaQuery({ maxWidth: breakpoints.sm });
+}
diff --git a/FrontEnd/src/components/hooks/useClickOutside.ts b/FrontEnd/src/components/hooks/useClickOutside.ts
new file mode 100644
index 00000000..828ce7e3
--- /dev/null
+++ b/FrontEnd/src/components/hooks/useClickOutside.ts
@@ -0,0 +1,38 @@
+import { useRef, useEffect } from "react";
+
+export default function useClickOutside(
+  element: HTMLElement | null | undefined,
+  onClickOutside: () => void,
+  nextTick?: boolean,
+): void {
+  const onClickOutsideRef = useRef<() => void>(onClickOutside);
+
+  useEffect(() => {
+    onClickOutsideRef.current = onClickOutside;
+  }, [onClickOutside]);
+
+  useEffect(() => {
+    if (element != null) {
+      const handler = (event: MouseEvent): void => {
+        let e: HTMLElement | null = event.target as HTMLElement;
+        while (e) {
+          if (e == element) {
+            return;
+          }
+          e = e.parentElement;
+        }
+        onClickOutsideRef.current();
+      };
+      if (nextTick) {
+        setTimeout(() => {
+          document.addEventListener("click", handler);
+        });
+      } else {
+        document.addEventListener("click", handler);
+      }
+      return () => {
+        document.removeEventListener("click", handler);
+      };
+    }
+  }, [element, nextTick]);
+}
diff --git a/FrontEnd/src/components/hooks/useScrollToBottom.ts b/FrontEnd/src/components/hooks/useScrollToBottom.ts
new file mode 100644
index 00000000..79fcda16
--- /dev/null
+++ b/FrontEnd/src/components/hooks/useScrollToBottom.ts
@@ -0,0 +1,44 @@
+import { useRef, useEffect } from "react";
+import { fromEvent, filter, throttleTime } from "rxjs";
+
+function useScrollToBottom(
+  handler: () => void,
+  enable = true,
+  option = {
+    maxOffset: 5,
+    throttle: 1000,
+  },
+): void {
+  const handlerRef = useRef<(() => void) | null>(null);
+
+  useEffect(() => {
+    handlerRef.current = handler;
+
+    return () => {
+      handlerRef.current = null;
+    };
+  }, [handler]);
+
+  useEffect(() => {
+    const subscription = fromEvent(window, "scroll")
+      .pipe(
+        filter(
+          () =>
+            window.scrollY >=
+            document.body.scrollHeight - window.innerHeight - option.maxOffset,
+        ),
+        throttleTime(option.throttle),
+      )
+      .subscribe(() => {
+        if (enable) {
+          handlerRef.current?.();
+        }
+      });
+
+    return () => {
+      subscription.unsubscribe();
+    };
+  }, [enable, option.maxOffset, option.throttle]);
+}
+
+export default useScrollToBottom;
diff --git a/FrontEnd/src/components/input/InputGroup.tsx b/FrontEnd/src/components/input/InputGroup.tsx
index 4f487344..47a43b38 100644
--- a/FrontEnd/src/components/input/InputGroup.tsx
+++ b/FrontEnd/src/components/input/InputGroup.tsx
@@ -72,12 +72,9 @@ export type InputDirtyDict = Record<string, boolean>;
 // use never so you don't have to cast everywhere
 export type InputConfirmValueDict = Record<string, never>;
 
-export type GeneralInputErrorDict =
-  | {
-      [key: string]: Text | null | undefined;
-    }
-  | null
-  | undefined;
+export type GeneralInputErrorDict = {
+  [key: string]: Text | null | undefined;
+};
 
 type MakeInputInfo<I extends Input> = Omit<I, "value" | "error" | "disabled">;
 
@@ -87,8 +84,9 @@ export type InputInfo = {
 
 export type Validator = (
   values: InputValueDict,
+  errors: GeneralInputErrorDict,
   inputs: InputInfo[],
-) => GeneralInputErrorDict;
+) => void;
 
 export type InputScheme = {
   inputs: InputInfo[];
@@ -157,7 +155,9 @@ function validate(
   values: InputValueDict,
   inputs: InputInfo[],
 ): InputErrorDict {
-  return cleanObject(validator?.(values, inputs) ?? {});
+  const errors: GeneralInputErrorDict = {};
+  validator?.(values, errors, inputs);
+  return cleanObject(errors);
 }
 
 export function useInputs(options: { init: Initializer }): {
diff --git a/FrontEnd/src/components/menu/Menu.tsx b/FrontEnd/src/components/menu/Menu.tsx
index e8099c76..c01c6cfb 100644
--- a/FrontEnd/src/components/menu/Menu.tsx
+++ b/FrontEnd/src/components/menu/Menu.tsx
@@ -2,9 +2,9 @@ import { CSSProperties } from "react";
 import classNames from "classnames";
 
 import { useC, Text, ThemeColor } from "../common";
+import Icon from "../Icon";
 
 import "./Menu.css";
-import Icon from "../Icon";
 
 export type MenuItem =
   | {
diff --git a/FrontEnd/src/components/menu/PopupMenu.tsx b/FrontEnd/src/components/menu/PopupMenu.tsx
index 23a67f79..9d90799d 100644
--- a/FrontEnd/src/components/menu/PopupMenu.tsx
+++ b/FrontEnd/src/components/menu/PopupMenu.tsx
@@ -3,11 +3,9 @@ import classNames from "classnames";
 import { createPortal } from "react-dom";
 import { usePopper } from "react-popper";
 
-import { useClickOutside } from "~src/utilities/hooks";
-
-import Menu, { MenuItems } from "./Menu";
-
 import { ThemeColor } from "../common";
+import { useClickOutside } from "../hooks";
+import Menu, { MenuItems } from "./Menu";
 
 import "./PopupMenu.css";
 
diff --git a/FrontEnd/src/pages/login/index.tsx b/FrontEnd/src/pages/login/index.tsx
index 582ebd0f..39ea3831 100644
--- a/FrontEnd/src/pages/login/index.tsx
+++ b/FrontEnd/src/pages/login/index.tsx
@@ -6,11 +6,7 @@ import { useUser, userService } from "~src/services/user";
 
 import { useC } from "~src/components/common";
 import LoadingButton from "~src/components/button/LoadingButton";
-import {
-  InputErrorDict,
-  InputGroup,
-  useInputs,
-} from "~src/components/input/InputGroup";
+import { InputGroup, useInputs } from "~src/components/input/InputGroup";
 import Page from "~src/components/Page";
 
 import "./index.css";
@@ -47,15 +43,13 @@ export default function LoginPage() {
               label: "user.rememberMe",
             },
           ],
-          validator: ({ username, password }) => {
-            const result: InputErrorDict = {};
+          validator: ({ username, password }, errors) => {
             if (username === "") {
-              result["username"] = "login.emptyUsername";
+              errors["username"] = "login.emptyUsername";
             }
             if (password === "") {
-              result["password"] = "login.emptyPassword";
+              errors["password"] = "login.emptyPassword";
             }
-            return result;
           },
         },
         dataInit: {},
diff --git a/FrontEnd/src/pages/register/index.tsx b/FrontEnd/src/pages/register/index.tsx
index 9e478612..fa25c2c2 100644
--- a/FrontEnd/src/pages/register/index.tsx
+++ b/FrontEnd/src/pages/register/index.tsx
@@ -7,11 +7,7 @@ import { getHttpTokenClient } from "~src/http/token";
 import { userService, useUser } from "~src/services/user";
 
 import { LoadingButton } from "~src/components/button";
-import {
-  useInputs,
-  InputErrorDict,
-  InputGroup,
-} from "~src/components/input/InputGroup";
+import { useInputs, InputGroup } from "~src/components/input/InputGroup";
 
 import "./index.css";
 
@@ -51,26 +47,22 @@ export default function RegisterPage() {
               label: "register.registerCode",
             },
           ],
-          validator: ({
-            username,
-            password,
-            confirmPassword,
-            registerCode,
-          }) => {
-            const result: InputErrorDict = {};
+          validator: (
+            { username, password, confirmPassword, registerCode },
+            errors,
+          ) => {
             if (username === "") {
-              result["username"] = "register.error.usernameEmpty";
+              errors["username"] = "register.error.usernameEmpty";
             }
             if (password === "") {
-              result["password"] = "register.error.passwordEmpty";
+              errors["password"] = "register.error.passwordEmpty";
             }
             if (confirmPassword !== password) {
-              result["confirmPassword"] = "register.error.confirmPasswordWrong";
+              errors["confirmPassword"] = "register.error.confirmPasswordWrong";
             }
             if (registerCode === "") {
-              result["registerCode"] = "register.error.registerCodeEmpty";
+              errors["registerCode"] = "register.error.registerCodeEmpty";
             }
-            return result;
           },
         },
         dataInit: {},
diff --git a/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx b/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx
index c34bcf4f..011c5059 100644
--- a/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx
+++ b/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx
@@ -11,9 +11,8 @@ import ImageCropper, {
   applyClipToImage,
 } from "~src/components/ImageCropper";
 import BlobImage from "~src/components/BlobImage";
-import ButtonRowV2 from "~src/components/button/ButtonRowV2";
-import Dialog from "~src/components/dialog/Dialog";
-import DialogContainer from "~src/components/dialog/DialogContainer";
+import { ButtonRowV2 } from "~src/components/button";
+import { Dialog, DialogContainer } from "~src/components/dialog";
 
 import "./ChangeAvatarDialog.css";
 
diff --git a/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx b/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx
index bfcea92d..946b9fbe 100644
--- a/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx
+++ b/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx
@@ -3,9 +3,7 @@ import { useNavigate } from "react-router-dom";
 
 import { userService } from "~src/services/user";
 
-import OperationDialog, {
-  InputErrorDict,
-} from "~src/components/dialog/OperationDialog";
+import { OperationDialog } from "~src/components/dialog";
 
 interface ChangePasswordDialogProps {
   open: boolean;
@@ -47,21 +45,22 @@ export function ChangePasswordDialog(props: ChangePasswordDialogProps) {
             password: true,
           },
         ],
-        validator: ({ oldPassword, newPassword, retypedNewPassword }) => {
-          const result: InputErrorDict = {};
+        validator: (
+          { oldPassword, newPassword, retypedNewPassword },
+          errors,
+        ) => {
           if (oldPassword === "") {
-            result["oldPassword"] =
+            errors["oldPassword"] =
               "settings.dialogChangePassword.errorEmptyOldPassword";
           }
           if (newPassword === "") {
-            result["newPassword"] =
+            errors["newPassword"] =
               "settings.dialogChangePassword.errorEmptyNewPassword";
           }
           if (retypedNewPassword !== newPassword) {
-            result["retypedNewPassword"] =
+            errors["retypedNewPassword"] =
               "settings.dialogChangePassword.errorRetypeNotMatch";
           }
-          return result;
         },
       }}
       onProcess={async ({ oldPassword, newPassword }) => {
diff --git a/FrontEnd/src/pages/setting/index.tsx b/FrontEnd/src/pages/setting/index.tsx
index 67416a08..918a77b5 100644
--- a/FrontEnd/src/pages/setting/index.tsx
+++ b/FrontEnd/src/pages/setting/index.tsx
@@ -4,25 +4,26 @@ import {
   ReactNode,
   ComponentPropsWithoutRef,
 } from "react";
-import { useTranslation } from "react-i18next";
+import { useTranslation } from "react-i18next"; // For change language.
 import { useNavigate } from "react-router-dom";
 import classNames from "classnames";
 
-import { useC, Text } from "~src/common";
 import { useUser, userService } from "~src/services/user";
 import { getHttpUserClient } from "~src/http/user";
+import { pushAlert } from "~src/services/alert";
+
+import { useC, Text } from "~src/common";
 
-import { useDialog } from "~src/components/dialog";
-import ConfirmDialog from "~src/components/dialog/ConfirmDialog";
+import { useDialog, ConfirmDialog } from "~src/components/dialog";
 import Card from "~src/components/Card";
 import Spinner from "~src/components/Spinner";
 import Page from "~src/components/Page";
+
 import ChangePasswordDialog from "./ChangePasswordDialog";
 import ChangeAvatarDialog from "./ChangeAvatarDialog";
 import ChangeNicknameDialog from "./ChangeNicknameDialog";
 
 import "./index.css";
-import { pushAlert } from "~src/services/alert";
 
 interface SettingSectionProps
   extends Omit<ComponentPropsWithoutRef<typeof Card>, "title"> {
diff --git a/FrontEnd/src/pages/timeline/Timeline.tsx b/FrontEnd/src/pages/timeline/Timeline.tsx
index f266ec9d..caf4f502 100644
--- a/FrontEnd/src/pages/timeline/Timeline.tsx
+++ b/FrontEnd/src/pages/timeline/Timeline.tsx
@@ -1,6 +1,5 @@
 import { useState, useEffect } from "react";
 import classnames from "classnames";
-import { useScrollToBottom } from "~src/utilities/hooks";
 import { HubConnectionState } from "@microsoft/signalr";
 
 import {
@@ -16,6 +15,8 @@ import {
 
 import { getTimelinePostUpdate$ } from "~src/services/timeline";
 
+import { useScrollToBottom } from "~src/components/hooks";
+
 import TimelinePostList from "./TimelinePostList";
 import TimelinePostEdit from "./TimelinePostCreateView";
 import TimelineCard from "./TimelineCard";
diff --git a/FrontEnd/src/pages/timeline/TimelineDeleteDialog.tsx b/FrontEnd/src/pages/timeline/TimelineDeleteDialog.tsx
index 7b7b8e8c..a7209e75 100644
--- a/FrontEnd/src/pages/timeline/TimelineDeleteDialog.tsx
+++ b/FrontEnd/src/pages/timeline/TimelineDeleteDialog.tsx
@@ -39,9 +39,9 @@ const TimelineDeleteDialog: React.FC<TimelineDeleteDialog> = (props) => {
             label: "",
           },
         ],
-        validator: ({ name }) => {
+        validator: ({ name }, errors) => {
           if (name !== timeline.nameV2) {
-            return { name: "timeline.deleteDialog.notMatch" };
+            errors.name = "timeline.deleteDialog.notMatch";
           }
         },
       }}
diff --git a/FrontEnd/src/pages/timeline/TimelinePostView.tsx b/FrontEnd/src/pages/timeline/TimelinePostView.tsx
index 2a8c5947..6b87ef2a 100644
--- a/FrontEnd/src/pages/timeline/TimelinePostView.tsx
+++ b/FrontEnd/src/pages/timeline/TimelinePostView.tsx
@@ -1,11 +1,13 @@
 import { useState } from "react";
 
-import { getHttpTimelineClient, HttpTimelinePostInfo } from "~src/http/timeline";
+import {
+  getHttpTimelineClient,
+  HttpTimelinePostInfo,
+} from "~src/http/timeline";
 
 import { pushAlert } from "~src/services/alert";
 
-import { useClickOutside } from "~src/utilities/hooks";
-
+import { useClickOutside } from "~src/components/hooks";
 import UserAvatar from "~src/components/user/UserAvatar";
 import { useDialog } from "~src/components/dialog";
 import FlatButton from "~src/components/button/FlatButton";
diff --git a/FrontEnd/src/utilities/hooks.ts b/FrontEnd/src/utilities/hooks.ts
deleted file mode 100644
index a59f7167..00000000
--- a/FrontEnd/src/utilities/hooks.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import useClickOutside from "./hooks/useClickOutside";
-import useScrollToBottom from "./hooks/useScrollToBottom";
-import { useIsSmallScreen } from "./hooks/mediaQuery";
-
-export { useClickOutside, useScrollToBottom, useIsSmallScreen };
diff --git a/FrontEnd/src/utilities/hooks/mediaQuery.ts b/FrontEnd/src/utilities/hooks/mediaQuery.ts
deleted file mode 100644
index ad55c3c0..00000000
--- a/FrontEnd/src/utilities/hooks/mediaQuery.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import { useMediaQuery } from "react-responsive";
-
-export function useIsSmallScreen(): boolean {
-  return useMediaQuery({ maxWidth: 576 });
-}
diff --git a/FrontEnd/src/utilities/hooks/useClickOutside.ts b/FrontEnd/src/utilities/hooks/useClickOutside.ts
deleted file mode 100644
index 6dcbf7b3..00000000
--- a/FrontEnd/src/utilities/hooks/useClickOutside.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import { useRef, useEffect } from "react";
-
-export default function useClickOutside(
-  element: HTMLElement | null | undefined,
-  onClickOutside: () => void,
-  nextTick?: boolean
-): void {
-  const onClickOutsideRef = useRef<() => void>(onClickOutside);
-
-  useEffect(() => {
-    onClickOutsideRef.current = onClickOutside;
-  }, [onClickOutside]);
-
-  useEffect(() => {
-    if (element != null) {
-      const handler = (event: MouseEvent): void => {
-        let e: HTMLElement | null = event.target as HTMLElement;
-        while (e) {
-          if (e == element) {
-            return;
-          }
-          e = e.parentElement;
-        }
-        onClickOutsideRef.current();
-      };
-      if (nextTick) {
-        setTimeout(() => {
-          document.addEventListener("click", handler);
-        });
-      } else {
-        document.addEventListener("click", handler);
-      }
-      return () => {
-        document.removeEventListener("click", handler);
-      };
-    }
-  }, [element, nextTick]);
-}
diff --git a/FrontEnd/src/utilities/hooks/useScrollToBottom.ts b/FrontEnd/src/utilities/hooks/useScrollToBottom.ts
deleted file mode 100644
index 216746f4..00000000
--- a/FrontEnd/src/utilities/hooks/useScrollToBottom.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import { useRef, useEffect } from "react";
-import { fromEvent } from "rxjs";
-import { filter, throttleTime } from "rxjs/operators";
-
-function useScrollToBottom(
-  handler: () => void,
-  enable = true,
-  option = {
-    maxOffset: 5,
-    throttle: 1000,
-  }
-): void {
-  const handlerRef = useRef<(() => void) | null>(null);
-
-  useEffect(() => {
-    handlerRef.current = handler;
-
-    return () => {
-      handlerRef.current = null;
-    };
-  }, [handler]);
-
-  useEffect(() => {
-    const subscription = fromEvent(window, "scroll")
-      .pipe(
-        filter(
-          () =>
-            window.scrollY >=
-            document.body.scrollHeight - window.innerHeight - option.maxOffset
-        ),
-        throttleTime(option.throttle)
-      )
-      .subscribe(() => {
-        if (enable) {
-          handlerRef.current?.();
-        }
-      });
-
-    return () => {
-      subscription.unsubscribe();
-    };
-  }, [enable, option.maxOffset, option.throttle]);
-}
-
-export default useScrollToBottom;
-- 
cgit v1.2.3


From b05860b6d2ea17db29a338659def49dc31082346 Mon Sep 17 00:00:00 2001
From: crupest <crupest@outlook.com>
Date: Tue, 29 Aug 2023 01:30:30 +0800
Subject: Refactor dialog module.

---
 FrontEnd/src/components/dialog/ConfirmDialog.tsx   |  14 +--
 FrontEnd/src/components/dialog/Dialog.tsx          |  18 +--
 FrontEnd/src/components/dialog/DialogProvider.tsx  |  95 +++++++++++++++
 FrontEnd/src/components/dialog/OperationDialog.tsx |  45 +++----
 FrontEnd/src/components/dialog/index.ts            |  65 -----------
 FrontEnd/src/components/dialog/index.tsx           |  12 ++
 FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx  |  32 +++--
 .../src/pages/setting/ChangeNicknameDialog.tsx     |  11 +-
 .../src/pages/setting/ChangePasswordDialog.tsx     |  11 +-
 FrontEnd/src/pages/setting/index.tsx               | 130 ++++++++++-----------
 FrontEnd/src/pages/timeline/MarkdownPostEdit.tsx   |  33 +++---
 FrontEnd/src/pages/timeline/TimelineCard.tsx       |  28 +++--
 .../src/pages/timeline/TimelineDeleteDialog.tsx    |   4 -
 .../src/pages/timeline/TimelinePostCreateView.tsx  |   2 +-
 FrontEnd/src/pages/timeline/TimelinePostView.tsx   |  48 ++++----
 .../timeline/TimelinePropertyChangeDialog.tsx      |   4 -
 16 files changed, 285 insertions(+), 267 deletions(-)
 create mode 100644 FrontEnd/src/components/dialog/DialogProvider.tsx
 delete mode 100644 FrontEnd/src/components/dialog/index.ts
 create mode 100644 FrontEnd/src/components/dialog/index.tsx

(limited to 'FrontEnd/src/components/dialog/OperationDialog.tsx')

diff --git a/FrontEnd/src/components/dialog/ConfirmDialog.tsx b/FrontEnd/src/components/dialog/ConfirmDialog.tsx
index 1d997305..a7b3917f 100644
--- a/FrontEnd/src/components/dialog/ConfirmDialog.tsx
+++ b/FrontEnd/src/components/dialog/ConfirmDialog.tsx
@@ -1,18 +1,16 @@
 import { useC, Text, ThemeColor } from "../common";
+
 import Dialog from "./Dialog";
 import DialogContainer from "./DialogContainer";
+import { useCloseDialog } from "./DialogProvider";
 
 export default function ConfirmDialog({
-  open,
-  onClose,
   onConfirm,
   title,
   body,
   color,
   bodyColor,
 }: {
-  open: boolean;
-  onClose: () => void;
   onConfirm: () => void;
   title: Text;
   body: Text;
@@ -21,8 +19,10 @@ export default function ConfirmDialog({
 }) {
   const c = useC();
 
+  const closeDialog = useCloseDialog();
+
   return (
-    <Dialog onClose={onClose} open={open}>
+    <Dialog>
       <DialogContainer
         title={title}
         titleColor={color ?? "danger"}
@@ -34,7 +34,7 @@ export default function ConfirmDialog({
               text: "operationDialog.cancel",
               color: "secondary",
               outline: true,
-              onClick: onClose,
+              onClick: closeDialog,
             },
           },
           {
@@ -45,7 +45,7 @@ export default function ConfirmDialog({
               color: "danger",
               onClick: () => {
                 onConfirm();
-                onClose();
+                closeDialog();
               },
             },
           },
diff --git a/FrontEnd/src/components/dialog/Dialog.tsx b/FrontEnd/src/components/dialog/Dialog.tsx
index 2ff7bea8..b1d66704 100644
--- a/FrontEnd/src/components/dialog/Dialog.tsx
+++ b/FrontEnd/src/components/dialog/Dialog.tsx
@@ -5,6 +5,8 @@ import classNames from "classnames";
 
 import { ThemeColor } from "../common";
 
+import { useCloseDialog } from "./DialogProvider";
+
 import "./Dialog.css";
 
 const optionalPortalElement = document.getElementById("portal");
@@ -14,22 +16,20 @@ if (optionalPortalElement == null) {
 const portalElement = optionalPortalElement;
 
 interface DialogProps {
-  open: boolean;
-  onClose: () => void;
   color?: ThemeColor;
   children?: ReactNode;
   disableCloseOnClickOnOverlay?: boolean;
 }
 
 export default function Dialog({
-  open,
-  onClose,
   color,
   children,
   disableCloseOnClickOnOverlay,
 }: DialogProps) {
   color = color ?? "primary";
 
+  const closeDialog = useCloseDialog();
+
   const nodeRef = useRef(null);
 
   return ReactDOM.createPortal(
@@ -37,7 +37,7 @@ export default function Dialog({
       nodeRef={nodeRef}
       mountOnEnter
       unmountOnExit
-      in={open}
+      in
       timeout={300}
       classNames="cru-dialog"
     >
@@ -47,13 +47,7 @@ export default function Dialog({
       >
         <div
           className="cru-dialog-background"
-          onClick={
-            disableCloseOnClickOnOverlay
-              ? undefined
-              : () => {
-                  onClose();
-                }
-          }
+          onClick={disableCloseOnClickOnOverlay ? undefined : closeDialog}
         />
         <div className="cru-dialog-container">{children}</div>
       </div>
diff --git a/FrontEnd/src/components/dialog/DialogProvider.tsx b/FrontEnd/src/components/dialog/DialogProvider.tsx
new file mode 100644
index 00000000..bb85e4cf
--- /dev/null
+++ b/FrontEnd/src/components/dialog/DialogProvider.tsx
@@ -0,0 +1,95 @@
+import { useState, useContext, createContext, ReactNode } from "react";
+
+import { UiLogicError } from "../common";
+
+type DialogMap<D extends string> = {
+  [K in D]: ReactNode;
+};
+
+interface DialogController<D extends string> {
+  currentDialog: D | null;
+  currentDialogReactNode: ReactNode;
+  canSwitchDialog: boolean;
+  switchDialog: (newDialog: D | null) => void;
+  setCanSwitchDialog: (enable: boolean) => void;
+  closeDialog: () => void;
+  forceSwitchDialog: (newDialog: D | null) => void;
+  forceCloseDialog: () => void;
+}
+
+export function useDialog<D extends string>(
+  dialogs: DialogMap<D>,
+  options?: {
+    initDialog?: D | null;
+    onClose?: {
+      [K in D]?: () => void;
+    };
+  },
+): {
+  controller: DialogController<D>;
+  switchDialog: (newDialog: D | null) => void;
+  forceSwitchDialog: (newDialog: D | null) => void;
+  createDialogSwitch: (newDialog: D | null) => () => void;
+} {
+  const [canSwitchDialog, setCanSwitchDialog] = useState<boolean>(true);
+  const [dialog, setDialog] = useState<D | null>(options?.initDialog ?? null);
+
+  const forceSwitchDialog = (newDialog: D | null) => {
+    if (dialog != null) {
+      options?.onClose?.[dialog]?.();
+    }
+    setDialog(newDialog);
+    setCanSwitchDialog(true);
+  };
+
+  const switchDialog = (newDialog: D | null) => {
+    if (canSwitchDialog) {
+      forceSwitchDialog(newDialog);
+    }
+  };
+
+  const controller: DialogController<D> = {
+    currentDialog: dialog,
+    currentDialogReactNode: dialog == null ? null : dialogs[dialog],
+    canSwitchDialog,
+    switchDialog,
+    setCanSwitchDialog,
+    closeDialog: () => switchDialog(null),
+    forceSwitchDialog,
+    forceCloseDialog: () => forceSwitchDialog(null),
+  };
+
+  return {
+    controller,
+    switchDialog,
+    forceSwitchDialog,
+    createDialogSwitch: (newDialog: D | null) => () => switchDialog(newDialog),
+  };
+}
+
+const DialogControllerContext = createContext<DialogController<string> | null>(
+  null,
+);
+
+export function useDialogController(): DialogController<string> {
+  const controller = useContext(DialogControllerContext);
+  if (controller == null) throw new UiLogicError("not in dialog provider");
+  return controller;
+}
+
+export function useCloseDialog(): () => void {
+  const controller = useDialogController();
+  return controller.closeDialog;
+}
+
+export function DialogProvider<D extends string>({
+  controller,
+}: {
+  controller: DialogController<D>;
+}) {
+  return (
+    <DialogControllerContext.Provider value={controller as never}>
+      {controller.currentDialogReactNode}
+    </DialogControllerContext.Provider>
+  );
+}
diff --git a/FrontEnd/src/components/dialog/OperationDialog.tsx b/FrontEnd/src/components/dialog/OperationDialog.tsx
index 96766825..902d60c6 100644
--- a/FrontEnd/src/components/dialog/OperationDialog.tsx
+++ b/FrontEnd/src/components/dialog/OperationDialog.tsx
@@ -11,6 +11,7 @@ import {
 import { ButtonRow } from "../button";
 import Dialog from "./Dialog";
 import DialogContainer from "./DialogContainer";
+import { useDialogController } from "./DialogProvider";
 
 import "./OperationDialog.css";
 
@@ -35,9 +36,6 @@ function OperationDialogPrompt(props: OperationDialogPromptProps) {
 }
 
 export interface OperationDialogProps<TData> {
-  open: boolean;
-  onClose: () => void;
-
   color?: ThemeColor;
   inputColor?: ThemeColor;
   title: Text;
@@ -56,8 +54,6 @@ export interface OperationDialogProps<TData> {
 
 function OperationDialog<TData>(props: OperationDialogProps<TData>) {
   const {
-    open,
-    onClose,
     color,
     inputColor,
     title,
@@ -96,6 +92,8 @@ function OperationDialog<TData>(props: OperationDialogProps<TData>) {
         data: unknown;
       };
 
+  const dialogController = useDialogController();
+
   const [step, setStep] = useState<Step>({ type: "input" });
 
   const { inputGroupProps, hasErrorAndDirty, setAllDisabled, confirm } =
@@ -105,7 +103,7 @@ function OperationDialog<TData>(props: OperationDialogProps<TData>) {
 
   function close() {
     if (step.type !== "process") {
-      onClose();
+      dialogController.closeDialog();
       if (step.type === "success" && onSuccessAndClose) {
         onSuccessAndClose?.(step.data);
       }
@@ -118,21 +116,26 @@ function OperationDialog<TData>(props: OperationDialogProps<TData>) {
     const result = confirm();
     if (result.type === "ok") {
       setStep({ type: "process" });
+      dialogController.setCanSwitchDialog(false);
       setAllDisabled(true);
-      onProcess(result.values).then(
-        (d) => {
-          setStep({
-            type: "success",
-            data: d,
-          });
-        },
-        (e: unknown) => {
-          setStep({
-            type: "failure",
-            data: e,
-          });
-        },
-      );
+      onProcess(result.values)
+        .then(
+          (d) => {
+            setStep({
+              type: "success",
+              data: d,
+            });
+          },
+          (e: unknown) => {
+            setStep({
+              type: "failure",
+              data: e,
+            });
+          },
+        )
+        .finally(() => {
+          dialogController.setCanSwitchDialog(true);
+        });
     }
   }
 
@@ -214,7 +217,7 @@ function OperationDialog<TData>(props: OperationDialogProps<TData>) {
   }
 
   return (
-    <Dialog open={open} onClose={close}>
+    <Dialog>
       <DialogContainer title={title} titleColor={color} buttons={buttons}>
         {body}
       </DialogContainer>
diff --git a/FrontEnd/src/components/dialog/index.ts b/FrontEnd/src/components/dialog/index.ts
deleted file mode 100644
index 17db8fd0..00000000
--- a/FrontEnd/src/components/dialog/index.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-import { useState } from "react";
-
-export { default as Dialog } from "./Dialog";
-export { default as FullPageDialog } from "./FullPageDialog";
-export { default as OperationDialog } from "./OperationDialog";
-export { default as ConfirmDialog } from "./ConfirmDialog";
-export { default as DialogContainer } from "./DialogContainer";
-
-type DialogMap<D extends string, V> = {
-  [K in D]: V;
-};
-
-type DialogKeyMap<D extends string> = DialogMap<D, number>;
-
-type DialogPropsMap<D extends string> = DialogMap<
-  D,
-  { key: number | string; open: boolean; onClose: () => void }
->;
-
-export function useDialog<D extends string>(
-  dialogs: D[],
-  options?: {
-    initDialog?: D | null;
-    onClose?: {
-      [K in D]?: () => void;
-    };
-  },
-): {
-  dialog: D | null;
-  switchDialog: (newDialog: D | null) => void;
-  dialogPropsMap: DialogPropsMap<D>;
-  createDialogSwitch: (newDialog: D | null) => () => void;
-} {
-  const [dialog, setDialog] = useState<D | null>(options?.initDialog ?? null);
-
-  const [dialogKeys, setDialogKeys] = useState<DialogKeyMap<D>>(
-    () => Object.fromEntries(dialogs.map((d) => [d, 0])) as DialogKeyMap<D>,
-  );
-
-  const switchDialog = (newDialog: D | null) => {
-    if (dialog !== null) {
-      setDialogKeys({ ...dialogKeys, [dialog]: dialogKeys[dialog] + 1 });
-    }
-    setDialog(newDialog);
-  };
-
-  return {
-    dialog,
-    switchDialog,
-    dialogPropsMap: Object.fromEntries(
-      dialogs.map((d) => [
-        d,
-        {
-          key: `${d}-${dialogKeys[d]}`,
-          open: dialog === d,
-          onClose: () => {
-            switchDialog(null);
-            options?.onClose?.[d]?.();
-          },
-        },
-      ]),
-    ) as DialogPropsMap<D>,
-    createDialogSwitch: (newDialog: D | null) => () => switchDialog(newDialog),
-  };
-}
diff --git a/FrontEnd/src/components/dialog/index.tsx b/FrontEnd/src/components/dialog/index.tsx
new file mode 100644
index 00000000..9ca06de2
--- /dev/null
+++ b/FrontEnd/src/components/dialog/index.tsx
@@ -0,0 +1,12 @@
+export { default as Dialog } from "./Dialog";
+export { default as FullPageDialog } from "./FullPageDialog";
+export { default as OperationDialog } from "./OperationDialog";
+export { default as ConfirmDialog } from "./ConfirmDialog";
+export { default as DialogContainer } from "./DialogContainer";
+
+export {
+  useDialog,
+  useDialogController,
+  useCloseDialog,
+  DialogProvider,
+} from "./DialogProvider";
diff --git a/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx b/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx
index 2fcfef2c..96ae971b 100644
--- a/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx
+++ b/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx
@@ -9,23 +9,21 @@ import { getHttpUserClient } from "~src/http/user";
 import { ImageCropper, useImageCrop } from "~src/components/ImageCropper";
 import BlobImage from "~src/components/BlobImage";
 import { ButtonRowV2 } from "~src/components/button";
-import { Dialog, DialogContainer } from "~src/components/dialog";
+import {
+  Dialog,
+  DialogContainer,
+  useDialogController,
+} from "~src/components/dialog";
 
 import "./ChangeAvatarDialog.css";
 
-interface ChangeAvatarDialogProps {
-  open: boolean;
-  onClose: () => void;
-}
-
-export default function ChangeAvatarDialog({
-  open,
-  onClose,
-}: ChangeAvatarDialogProps) {
+export default function ChangeAvatarDialog() {
   const c = useC();
 
   const user = useUser();
 
+  const controller = useDialogController();
+
   type State =
     | "select"
     | "crop"
@@ -49,11 +47,7 @@ export default function ChangeAvatarDialog({
     "settings.dialogChangeAvatar.prompt.select",
   );
 
-  const close = (): void => {
-    if (state !== "uploading") {
-      onClose();
-    }
-  };
+  const close = controller.closeDialog;
 
   const onSelectFile = (e: ChangeEvent<HTMLInputElement>): void => {
     const files = e.target.files;
@@ -96,6 +90,7 @@ export default function ChangeAvatarDialog({
     }
 
     setState("uploading");
+    controller.setCanSwitchDialog(false);
     getHttpUserClient()
       .putAvatar(user.username, resultBlob)
       .then(
@@ -106,7 +101,10 @@ export default function ChangeAvatarDialog({
           setState("error");
           setMessage("operationDialog.error");
         },
-      );
+      )
+      .finally(() => {
+        controller.setCanSwitchDialog(true);
+      });
   };
 
   const cancelButton = {
@@ -181,7 +179,7 @@ export default function ChangeAvatarDialog({
   };
 
   return (
-    <Dialog open={open} onClose={close}>
+    <Dialog>
       <DialogContainer
         title="settings.dialogChangeAvatar.title"
         titleColor="primary"
diff --git a/FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx b/FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx
index 843659ef..bd1eaa51 100644
--- a/FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx
+++ b/FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx
@@ -3,19 +3,11 @@ import { useUserLoggedIn } from "~src/services/user";
 
 import OperationDialog from "~src/components/dialog/OperationDialog";
 
-export interface ChangeNicknameDialogProps {
-  open: boolean;
-  onClose: () => void;
-}
-
-export default function ChangeNicknameDialog(props: ChangeNicknameDialogProps) {
-  const { open, onClose } = props;
-
+export default function ChangeNicknameDialog() {
   const user = useUserLoggedIn();
 
   return (
     <OperationDialog
-      open={open}
       title="settings.dialogChangeNickname.title"
       inputs={[
         {
@@ -29,7 +21,6 @@ export default function ChangeNicknameDialog(props: ChangeNicknameDialogProps) {
           nickname: newNickname,
         });
       }}
-      onClose={onClose}
     />
   );
 }
diff --git a/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx b/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx
index 946b9fbe..c3111ac8 100644
--- a/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx
+++ b/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx
@@ -5,22 +5,13 @@ import { userService } from "~src/services/user";
 
 import { OperationDialog } from "~src/components/dialog";
 
-interface ChangePasswordDialogProps {
-  open: boolean;
-  onClose: () => void;
-}
-
-export function ChangePasswordDialog(props: ChangePasswordDialogProps) {
-  const { open, onClose } = props;
-
+export function ChangePasswordDialog() {
   const navigate = useNavigate();
 
   const [redirect, setRedirect] = useState<boolean>(false);
 
   return (
     <OperationDialog
-      open={open}
-      onClose={onClose}
       title="settings.dialogChangePassword.title"
       color="danger"
       inputPrompt="settings.dialogChangePassword.prompt"
diff --git a/FrontEnd/src/pages/setting/index.tsx b/FrontEnd/src/pages/setting/index.tsx
index 918a77b5..4d2c28c7 100644
--- a/FrontEnd/src/pages/setting/index.tsx
+++ b/FrontEnd/src/pages/setting/index.tsx
@@ -14,7 +14,11 @@ import { pushAlert } from "~src/services/alert";
 
 import { useC, Text } from "~src/common";
 
-import { useDialog, ConfirmDialog } from "~src/components/dialog";
+import {
+  useDialog,
+  DialogProvider,
+  ConfirmDialog,
+} from "~src/components/dialog";
 import Card from "~src/components/Card";
 import Spinner from "~src/components/Spinner";
 import Page from "~src/components/Page";
@@ -140,7 +144,22 @@ function RegisterCodeSettingItem() {
   // undefined: loading
   const [registerCode, setRegisterCode] = useState<undefined | null | string>();
 
-  const [dialogOpen, setDialogOpen] = useState(false);
+  const { controller, createDialogSwitch } = useDialog({
+    confirm: (
+      <ConfirmDialog
+        title="settings.renewRegisterCode"
+        body="settings.renewRegisterCodeDesc"
+        onConfirm={() => {
+          if (user == null) throw new Error();
+          void getHttpUserClient()
+            .renewRegisterCode(user.username)
+            .then(() => {
+              setRegisterCode(undefined);
+            });
+        }}
+      />
+    ),
+  });
 
   useEffect(() => {
     setRegisterCode(undefined);
@@ -157,49 +176,34 @@ function RegisterCodeSettingItem() {
   }, [user, registerCode]);
 
   return (
-    <>
-      <SettingItemContainer
-        title="settings.myRegisterCode"
-        description="settings.myRegisterCodeDesc"
-        className="register-code-setting-item"
-        onClick={() => setDialogOpen(true)}
-      >
-        {registerCode === undefined ? (
-          <Spinner />
-        ) : registerCode === null ? (
-          <span>Noop</span>
-        ) : (
-          <code
-            className="register-code"
-            onClick={(event) => {
-              void navigator.clipboard.writeText(registerCode).then(() => {
-                pushAlert({
-                  type: "create",
-                  message: "settings.myRegisterCodeCopied",
-                });
+    <SettingItemContainer
+      title="settings.myRegisterCode"
+      description="settings.myRegisterCodeDesc"
+      className="register-code-setting-item"
+      onClick={createDialogSwitch("confirm")}
+    >
+      {registerCode === undefined ? (
+        <Spinner />
+      ) : registerCode === null ? (
+        <span>Noop</span>
+      ) : (
+        <code
+          className="register-code"
+          onClick={(event) => {
+            void navigator.clipboard.writeText(registerCode).then(() => {
+              pushAlert({
+                type: "create",
+                message: "settings.myRegisterCodeCopied",
               });
-              event.stopPropagation();
-            }}
-          >
-            {registerCode}
-          </code>
-        )}
-      </SettingItemContainer>
-      <ConfirmDialog
-        title="settings.renewRegisterCode"
-        body="settings.renewRegisterCodeDesc"
-        onClose={() => setDialogOpen(false)}
-        open={dialogOpen}
-        onConfirm={() => {
-          if (user == null) throw new Error();
-          void getHttpUserClient()
-            .renewRegisterCode(user.username)
-            .then(() => {
-              setRegisterCode(undefined);
             });
-        }}
-      />{" "}
-    </>
+            event.stopPropagation();
+          }}
+        >
+          {registerCode}
+        </code>
+      )}
+      <DialogProvider controller={controller} />
+    </SettingItemContainer>
   );
 }
 
@@ -240,12 +244,22 @@ export default function SettingPage() {
   const user = useUser();
   const navigate = useNavigate();
 
-  const { dialogPropsMap, createDialogSwitch } = useDialog([
-    "change-password",
-    "change-avatar",
-    "change-nickname",
-    "logout",
-  ]);
+  const { controller, createDialogSwitch } = useDialog({
+    "change-password": <ChangeNicknameDialog />,
+    "change-avatar": <ChangeAvatarDialog />,
+    "change-nickname": <ChangePasswordDialog />,
+    logout: (
+      <ConfirmDialog
+        title="settings.dialogConfirmLogout.title"
+        body="settings.dialogConfirmLogout.prompt"
+        onConfirm={() => {
+          void userService.logout().then(() => {
+            navigate("/");
+          });
+        }}
+      />
+    ),
+  });
 
   return (
     <Page noTopPadding>
@@ -275,23 +289,7 @@ export default function SettingPage() {
       <SettingSection title="settings.subheader.customization">
         <LanguageChangeSettingItem />
       </SettingSection>
-      <ChangePasswordDialog {...dialogPropsMap["change-password"]} />
-      {user && (
-        <>
-          <ConfirmDialog
-            title="settings.dialogConfirmLogout.title"
-            body="settings.dialogConfirmLogout.prompt"
-            onConfirm={() => {
-              void userService.logout().then(() => {
-                navigate("/");
-              });
-            }}
-            {...dialogPropsMap["logout"]}
-          />
-          <ChangeAvatarDialog {...dialogPropsMap["change-avatar"]} />
-          <ChangeNicknameDialog {...dialogPropsMap["change-nickname"]} />
-        </>
-      )}
+      <DialogProvider controller={controller} />
     </Page>
   );
 }
diff --git a/FrontEnd/src/pages/timeline/MarkdownPostEdit.tsx b/FrontEnd/src/pages/timeline/MarkdownPostEdit.tsx
index 43e81d67..fc7b882f 100644
--- a/FrontEnd/src/pages/timeline/MarkdownPostEdit.tsx
+++ b/FrontEnd/src/pages/timeline/MarkdownPostEdit.tsx
@@ -2,7 +2,10 @@ import * as React from "react";
 import classnames from "classnames";
 import { useTranslation } from "react-i18next";
 
-import { getHttpTimelineClient, HttpTimelinePostInfo } from "~src/http/timeline";
+import {
+  getHttpTimelineClient,
+  HttpTimelinePostInfo,
+} from "~src/http/timeline";
 
 import TimelinePostBuilder from "~src/services/TimelinePostBuilder";
 
@@ -13,6 +16,7 @@ import Spinner from "~src/components/Spinner";
 import IconButton from "~src/components/button/IconButton";
 
 import "./MarkdownPostEdit.css";
+import { DialogProvider, useDialog } from "~src/components/dialog";
 
 export interface MarkdownPostEditProps {
   owner: string;
@@ -39,12 +43,19 @@ const MarkdownPostEdit: React.FC<MarkdownPostEditProps> = ({
 
   const [process, setProcess] = React.useState<boolean>(false);
 
-  const [showLeaveConfirmDialog, setShowLeaveConfirmDialog] =
-    React.useState<boolean>(false);
+  const { controller, switchDialog } = useDialog({
+    "leave-confirm": (
+      <ConfirmDialog
+        onConfirm={onClose}
+        title="timeline.dropDraft"
+        body="timeline.confirmLeave"
+      />
+    ),
+  });
 
   const [text, _setText] = React.useState<string>("");
   const [images, _setImages] = React.useState<{ file: File; url: string }[]>(
-    []
+    [],
   );
   const [previewHtml, _setPreviewHtml] = React.useState<string>("");
 
@@ -92,7 +103,7 @@ const MarkdownPostEdit: React.FC<MarkdownPostEditProps> = ({
         timelineName,
         {
           dataList,
-        }
+        },
       );
       onPosted(post);
       onClose();
@@ -123,7 +134,7 @@ const MarkdownPostEdit: React.FC<MarkdownPostEditProps> = ({
                   if (canLeave) {
                     onClose();
                   } else {
-                    setShowLeaveConfirmDialog(true);
+                    switchDialog("leave-confirm");
                   }
                 }}
               />
@@ -167,7 +178,7 @@ const MarkdownPostEdit: React.FC<MarkdownPostEditProps> = ({
                       color="danger"
                       className={classnames(
                         "timeline-markdown-post-edit-image-delete-button",
-                        process && "d-none"
+                        process && "d-none",
                       )}
                       onClick={() => {
                         getBuilder().deleteImage(index);
@@ -201,13 +212,7 @@ const MarkdownPostEdit: React.FC<MarkdownPostEditProps> = ({
           },
         ]}
       />
-      <ConfirmDialog
-        onClose={() => setShowLeaveConfirmDialog(false)}
-        onConfirm={onClose}
-        open={showLeaveConfirmDialog}
-        title="timeline.dropDraft"
-        body="timeline.confirmLeave"
-      />
+      <DialogProvider controller={controller} />
     </>
   );
 };
diff --git a/FrontEnd/src/pages/timeline/TimelineCard.tsx b/FrontEnd/src/pages/timeline/TimelineCard.tsx
index f17a3ce9..133f1ef4 100644
--- a/FrontEnd/src/pages/timeline/TimelineCard.tsx
+++ b/FrontEnd/src/pages/timeline/TimelineCard.tsx
@@ -8,7 +8,7 @@ import { HttpTimelineInfo } from "~src/http/timeline";
 import { getHttpBookmarkClient } from "~src/http/bookmark";
 
 import { useMobile } from "~src/components/hooks";
-import { Dialog, useDialog } from "~src/components/dialog";
+import { Dialog, DialogProvider, useDialog } from "~src/components/dialog";
 import UserAvatar from "~src/components/user/UserAvatar";
 import PopupMenu from "~src/components/menu/PopupMenu";
 import FullPageDialog from "~src/components/dialog/FullPageDialog";
@@ -40,11 +40,17 @@ export default function TimelineCard(props: TimelinePageCardProps) {
 
   const isMobile = useMobile();
 
-  const { createDialogSwitch, dialogPropsMap } = useDialog([
-    "member",
-    "property",
-    "delete",
-  ]);
+  const { controller, createDialogSwitch } = useDialog({
+    member: (
+      <Dialog>
+        <TimelineMember timeline={timeline} onChange={onReload} />
+      </Dialog>
+    ),
+    property: (
+      <TimelinePropertyChangeDialog timeline={timeline} onChange={onReload} />
+    ),
+    delete: <TimelineDeleteDialog timeline={timeline} />,
+  });
 
   const content = (
     <div>
@@ -144,15 +150,7 @@ export default function TimelineCard(props: TimelinePageCardProps) {
       ) : (
         <div style={{ display: collapse ? "none" : "block" }}>{content}</div>
       )}
-      <Dialog {...dialogPropsMap["member"]}>
-        <TimelineMember timeline={timeline} onChange={onReload} />
-      </Dialog>
-      <TimelinePropertyChangeDialog
-        timeline={timeline}
-        onChange={onReload}
-        {...dialogPropsMap["property"]}
-      />
-      <TimelineDeleteDialog timeline={timeline} {...dialogPropsMap["delete"]} />
+      <DialogProvider controller={controller} />
     </Card>
   );
 }
diff --git a/FrontEnd/src/pages/timeline/TimelineDeleteDialog.tsx b/FrontEnd/src/pages/timeline/TimelineDeleteDialog.tsx
index a7209e75..630ce4ca 100644
--- a/FrontEnd/src/pages/timeline/TimelineDeleteDialog.tsx
+++ b/FrontEnd/src/pages/timeline/TimelineDeleteDialog.tsx
@@ -8,8 +8,6 @@ import OperationDialog from "~src/components/dialog/OperationDialog";
 
 interface TimelineDeleteDialog {
   timeline: HttpTimelineInfo;
-  open: boolean;
-  onClose: () => void;
 }
 
 const TimelineDeleteDialog: React.FC<TimelineDeleteDialog> = (props) => {
@@ -19,8 +17,6 @@ const TimelineDeleteDialog: React.FC<TimelineDeleteDialog> = (props) => {
 
   return (
     <OperationDialog
-      open={props.open}
-      onClose={props.onClose}
       title="timeline.deleteDialog.title"
       color="danger"
       inputPromptNode={
diff --git a/FrontEnd/src/pages/timeline/TimelinePostCreateView.tsx b/FrontEnd/src/pages/timeline/TimelinePostCreateView.tsx
index 3c41228a..3bc4dab3 100644
--- a/FrontEnd/src/pages/timeline/TimelinePostCreateView.tsx
+++ b/FrontEnd/src/pages/timeline/TimelinePostCreateView.tsx
@@ -88,7 +88,7 @@ function TimelinePostEditImage(props: TimelinePostEditImageProps) {
       />
       {file != null && !error && (
         <BlobImage
-          blob={file}
+          src={file}
           className="timeline-post-create-image"
           onLoad={() => onSelect(file)}
           onError={() => {
diff --git a/FrontEnd/src/pages/timeline/TimelinePostView.tsx b/FrontEnd/src/pages/timeline/TimelinePostView.tsx
index 6b87ef2a..5de09b28 100644
--- a/FrontEnd/src/pages/timeline/TimelinePostView.tsx
+++ b/FrontEnd/src/pages/timeline/TimelinePostView.tsx
@@ -9,7 +9,7 @@ import { pushAlert } from "~src/services/alert";
 
 import { useClickOutside } from "~src/components/hooks";
 import UserAvatar from "~src/components/user/UserAvatar";
-import { useDialog } from "~src/components/dialog";
+import { DialogProvider, useDialog } from "~src/components/dialog";
 import FlatButton from "~src/components/button/FlatButton";
 import ConfirmDialog from "~src/components/dialog/ConfirmDialog";
 import TimelinePostContentView from "./TimelinePostContentView";
@@ -33,13 +33,33 @@ export default function TimelinePostView(props: TimelinePostViewProps) {
   const [operationMaskVisible, setOperationMaskVisible] =
     useState<boolean>(false);
 
-  const { switchDialog, dialogPropsMap } = useDialog(["delete"], {
-    onClose: {
-      delete: () => {
-        setOperationMaskVisible(false);
+  const { controller, switchDialog } = useDialog(
+    {
+      delete: (
+        <ConfirmDialog
+          title="timeline.post.deleteDialog.title"
+          body="timeline.post.deleteDialog.prompt"
+          onConfirm={() => {
+            void getHttpTimelineClient()
+              .deletePost(post.timelineOwnerV2, post.timelineNameV2, post.id)
+              .then(onDeleted, () => {
+                pushAlert({
+                  type: "danger",
+                  message: "timeline.deletePostFailed",
+                });
+              });
+          }}
+        />
+      ),
+    },
+    {
+      onClose: {
+        delete: () => {
+          setOperationMaskVisible(false);
+        },
       },
     },
-  });
+  );
 
   const [maskElement, setMaskElement] = useState<HTMLElement | null>(null);
   useClickOutside(maskElement, () => setOperationMaskVisible(false));
@@ -98,21 +118,7 @@ export default function TimelinePostView(props: TimelinePostViewProps) {
           </div>
         ) : null}
       </TimelinePostCard>
-      <ConfirmDialog
-        title="timeline.post.deleteDialog.title"
-        body="timeline.post.deleteDialog.prompt"
-        onConfirm={() => {
-          void getHttpTimelineClient()
-            .deletePost(post.timelineOwnerV2, post.timelineNameV2, post.id)
-            .then(onDeleted, () => {
-              pushAlert({
-                type: "danger",
-                message: "timeline.deletePostFailed",
-              });
-            });
-        }}
-        {...dialogPropsMap.delete}
-      />
+      <DialogProvider controller={controller} />
     </TimelinePostContainer>
   );
 }
diff --git a/FrontEnd/src/pages/timeline/TimelinePropertyChangeDialog.tsx b/FrontEnd/src/pages/timeline/TimelinePropertyChangeDialog.tsx
index afd83a5f..ee5388cb 100644
--- a/FrontEnd/src/pages/timeline/TimelinePropertyChangeDialog.tsx
+++ b/FrontEnd/src/pages/timeline/TimelinePropertyChangeDialog.tsx
@@ -11,8 +11,6 @@ import {
 import OperationDialog from "~src/components/dialog/OperationDialog";
 
 export interface TimelinePropertyChangeDialogProps {
-  open: boolean;
-  onClose: () => void;
   timeline: HttpTimelineInfo;
   onChange: () => void;
 }
@@ -63,8 +61,6 @@ const TimelinePropertyChangeDialog: React.FC<
           },
         },
       }}
-      open={props.open}
-      onClose={props.onClose}
       onProcess={({ title, visibility, description }) => {
         const req: HttpTimelinePatchRequest = {};
         if (title !== timeline.title) {
-- 
cgit v1.2.3


From 5c624ecb5c7e33039d9f14dbce099e4874efb23b Mon Sep 17 00:00:00 2001
From: crupest <crupest@outlook.com>
Date: Wed, 30 Aug 2023 00:34:47 +0800
Subject: ...

---
 FrontEnd/src/components/dialog/ConfirmDialog.tsx   |  5 ++---
 FrontEnd/src/components/dialog/Dialog.css          | 24 ++--------------------
 FrontEnd/src/components/dialog/Dialog.tsx          |  9 +++++++-
 FrontEnd/src/components/dialog/DialogContainer.css |  3 +--
 FrontEnd/src/components/dialog/DialogContainer.tsx |  2 +-
 FrontEnd/src/components/dialog/OperationDialog.css |  4 ----
 FrontEnd/src/components/dialog/OperationDialog.tsx |  2 +-
 FrontEnd/src/components/theme.css                  | 16 +++++++++++++++
 8 files changed, 31 insertions(+), 34 deletions(-)

(limited to 'FrontEnd/src/components/dialog/OperationDialog.tsx')

diff --git a/FrontEnd/src/components/dialog/ConfirmDialog.tsx b/FrontEnd/src/components/dialog/ConfirmDialog.tsx
index a7b3917f..97cad452 100644
--- a/FrontEnd/src/components/dialog/ConfirmDialog.tsx
+++ b/FrontEnd/src/components/dialog/ConfirmDialog.tsx
@@ -9,7 +9,6 @@ export default function ConfirmDialog({
   title,
   body,
   color,
-  bodyColor,
 }: {
   onConfirm: () => void;
   title: Text;
@@ -22,7 +21,7 @@ export default function ConfirmDialog({
   const closeDialog = useCloseDialog();
 
   return (
-    <Dialog>
+    <Dialog color={color ?? "danger"}>
       <DialogContainer
         title={title}
         titleColor={color ?? "danger"}
@@ -51,7 +50,7 @@ export default function ConfirmDialog({
           },
         ]}
       >
-        <div className={`cru-${bodyColor ?? "primary"}`}>{c(body)}</div>
+        <div>{c(body)}</div>
       </DialogContainer>
     </Dialog>
   );
diff --git a/FrontEnd/src/components/dialog/Dialog.css b/FrontEnd/src/components/dialog/Dialog.css
index f25309ae..e4f52a92 100644
--- a/FrontEnd/src/components/dialog/Dialog.css
+++ b/FrontEnd/src/components/dialog/Dialog.css
@@ -27,34 +27,14 @@
 
   margin: 2em auto;
 
-  border: var(--cru-dialog-container-background-color) 1px solid;
+  border: var(--cru-theme-color) 2px solid;
   border-radius: 5px;
   padding: 1.5em;
-  background-color: var(--cru-surface-color);
+  background-color: var(--cru-dialog-container-background-color);
 }
 
 @media (min-width: 576px) {
   .cru-dialog-container {
     max-width: 800px;
   }
-}
-
-.cru-dialog-enter .cru-dialog-container {
-  transform: scale(0, 0);
-  opacity: 0;
-  transform-origin: center;
-}
-
-.cru-dialog-enter-active .cru-dialog-container {
-  transform: scale(1, 1);
-  opacity: 1;
-  transition: transform 0.3s, opacity 0.3s;
-  transform-origin: center;
-}
-
-.cru-dialog-exit-active .cru-dialog-container {
-  transition: transform 0.3s, opacity 0.3s;
-  transform: scale(0, 0);
-  opacity: 0;
-  transform-origin: center;
 }
\ No newline at end of file
diff --git a/FrontEnd/src/components/dialog/Dialog.tsx b/FrontEnd/src/components/dialog/Dialog.tsx
index bdba9198..85e8ca46 100644
--- a/FrontEnd/src/components/dialog/Dialog.tsx
+++ b/FrontEnd/src/components/dialog/Dialog.tsx
@@ -15,11 +15,13 @@ if (optionalPortalElement == null) {
 const portalElement = optionalPortalElement;
 
 interface DialogProps {
+  color?: ThemeColor;
   children?: ReactNode;
   disableCloseOnClickOnOverlay?: boolean;
 }
 
 export default function Dialog({
+  color,
   children,
   disableCloseOnClickOnOverlay,
 }: DialogProps) {
@@ -28,7 +30,12 @@ export default function Dialog({
   const lastPointerDownIdRef = useRef<number | null>(null);
 
   return ReactDOM.createPortal(
-    <div className={classNames("cru-dialog-overlay")}>
+    <div
+      className={classNames(
+        `cru-theme-${color ?? "primary"}`,
+        "cru-dialog-overlay",
+      )}
+    >
       <div
         className="cru-dialog-background"
         onPointerDown={(e) => {
diff --git a/FrontEnd/src/components/dialog/DialogContainer.css b/FrontEnd/src/components/dialog/DialogContainer.css
index fbb18e0d..b3c52511 100644
--- a/FrontEnd/src/components/dialog/DialogContainer.css
+++ b/FrontEnd/src/components/dialog/DialogContainer.css
@@ -1,11 +1,10 @@
 .cru-dialog-container-title {
   font-size: 1.2em;
   font-weight: bold;
-  color: var(--cru-key-color);
+  color: var(--cru-theme-color);
   margin-bottom: 0.5em;
 }
 
-
 .cru-dialog-container-hr {
   margin: 1em 0;
 }
diff --git a/FrontEnd/src/components/dialog/DialogContainer.tsx b/FrontEnd/src/components/dialog/DialogContainer.tsx
index afee2669..6ee4e134 100644
--- a/FrontEnd/src/components/dialog/DialogContainer.tsx
+++ b/FrontEnd/src/components/dialog/DialogContainer.tsx
@@ -52,7 +52,7 @@ export default function DialogContainer(props: DialogContainerProps) {
       <div
         ref={titleRef}
         className={classNames(
-          `cru-dialog-container-title cru-${titleColor ?? "primary"}`,
+          `cru-dialog-container-title cru-theme-${titleColor ?? "primary"}`,
           titleClassName,
         )}
       >
diff --git a/FrontEnd/src/components/dialog/OperationDialog.css b/FrontEnd/src/components/dialog/OperationDialog.css
index f4b7237e..28f73c9d 100644
--- a/FrontEnd/src/components/dialog/OperationDialog.css
+++ b/FrontEnd/src/components/dialog/OperationDialog.css
@@ -1,7 +1,3 @@
-.cru-operation-dialog-prompt {
-  color: var(--cru-surface-on-color);
-}
-
 .cru-operation-dialog-input-group {
   display: block;
   margin: 0.5em 0;
diff --git a/FrontEnd/src/components/dialog/OperationDialog.tsx b/FrontEnd/src/components/dialog/OperationDialog.tsx
index 902d60c6..4b4ceb36 100644
--- a/FrontEnd/src/components/dialog/OperationDialog.tsx
+++ b/FrontEnd/src/components/dialog/OperationDialog.tsx
@@ -217,7 +217,7 @@ function OperationDialog<TData>(props: OperationDialogProps<TData>) {
   }
 
   return (
-    <Dialog>
+    <Dialog color={color}>
       <DialogContainer title={title} titleColor={color} buttons={buttons}>
         {body}
       </DialogContainer>
diff --git a/FrontEnd/src/components/theme.css b/FrontEnd/src/components/theme.css
index d7e30d1a..67340b6f 100644
--- a/FrontEnd/src/components/theme.css
+++ b/FrontEnd/src/components/theme.css
@@ -14,6 +14,22 @@
   --cru-danger-color: hsl(0 100% 50%);
 }
 
+.cru-theme-primary {
+  --cru-theme-color: var(--cru-primary-color);
+}
+
+.cru-theme-secondary {
+  --cru-theme-color: var(--cru-secondary-color);
+}
+
+.cru-theme-create {
+  --cru-theme-color: var(--cru-create-color);
+}
+
+.cru-theme-danger {
+  --cru-theme-color: var(--cru-danger-color);
+}
+
 /* common colors */
 :root {
   --cru-background-color: hsl(0 0% 100%);
-- 
cgit v1.2.3


From 9c69024cf5961c3c71fb58e4237f09a513d195b1 Mon Sep 17 00:00:00 2001
From: crupest <crupest@outlook.com>
Date: Thu, 31 Aug 2023 00:30:35 +0800
Subject: Minor buttons

---
 FrontEnd/src/components/button/Button.tsx          |  4 +--
 FrontEnd/src/components/button/ButtonRowV2.tsx     | 14 ++++----
 FrontEnd/src/components/button/FlatButton.tsx      |  4 +--
 FrontEnd/src/components/button/IconButton.tsx      |  4 +--
 FrontEnd/src/components/button/LoadingButton.tsx   |  4 +--
 FrontEnd/src/components/common.ts                  |  2 ++
 FrontEnd/src/components/dialog/FullPageDialog.tsx  |  4 +--
 FrontEnd/src/components/dialog/OperationDialog.tsx | 39 +++++++++-------------
 FrontEnd/src/components/index.css                  |  2 +-
 FrontEnd/src/components/theme.css                  | 23 ++++++++++---
 FrontEnd/src/pages/setting/index.css               |  4 +--
 FrontEnd/src/pages/timeline/TimelineCard.css       |  4 +--
 .../src/pages/timeline/TimelinePostCreateView.css  |  4 +--
 13 files changed, 62 insertions(+), 50 deletions(-)

(limited to 'FrontEnd/src/components/dialog/OperationDialog.tsx')

diff --git a/FrontEnd/src/components/button/Button.tsx b/FrontEnd/src/components/button/Button.tsx
index 6c38e130..30ea8c11 100644
--- a/FrontEnd/src/components/button/Button.tsx
+++ b/FrontEnd/src/components/button/Button.tsx
@@ -1,12 +1,12 @@
 import { ComponentPropsWithoutRef, Ref } from "react";
 import classNames from "classnames";
 
-import { Text, useC, ThemeColor } from "../common";
+import { Text, useC, ClickableColor } from "../common";
 
 import "./Button.css";
 
 interface ButtonProps extends ComponentPropsWithoutRef<"button"> {
-  color?: ThemeColor;
+  color?: ClickableColor;
   text?: Text;
   outline?: boolean;
   buttonRef?: Ref<HTMLButtonElement> | null;
diff --git a/FrontEnd/src/components/button/ButtonRowV2.tsx b/FrontEnd/src/components/button/ButtonRowV2.tsx
index 5129e7f1..a54425cc 100644
--- a/FrontEnd/src/components/button/ButtonRowV2.tsx
+++ b/FrontEnd/src/components/button/ButtonRowV2.tsx
@@ -1,7 +1,7 @@
 import { ComponentPropsWithoutRef, Ref } from "react";
 import classNames from "classnames";
 
-import { Text, ThemeColor } from "../common";
+import { Text, ClickableColor } from "../common";
 
 import Button from "./Button";
 import FlatButton from "./FlatButton";
@@ -10,10 +10,12 @@ import LoadingButton from "./LoadingButton";
 
 import "./ButtonRow.css";
 
+type ButtonAction = "major" | "minor";
+
 interface ButtonRowV2ButtonBase {
   key: string | number;
-  action?: "primary" | "secondary";
-  color?: ThemeColor;
+  action?: ButtonAction;
+  color?: ClickableColor;
   disabled?: boolean;
   onClick?: () => void;
 }
@@ -76,9 +78,9 @@ export default function ButtonRowV2({
       {buttons.map((button) => {
         const { key, action, color, disabled, onClick } = button;
 
-        const realAction = action ?? "primary";
+        const realAction: ButtonAction = action ?? "minor";
         const realColor =
-          color ?? (realAction === "primary" ? "primary" : "secondary");
+          color ?? (realAction === "major" ? "primary" : "minor");
 
         const commonProps = { key, color: realColor, disabled, onClick };
         const newClassName = classNames(
@@ -95,7 +97,7 @@ export default function ButtonRowV2({
               <Button
                 {...commonProps}
                 text={text}
-                outline={outline ?? realAction !== "primary"}
+                outline={outline ?? realAction !== "major"}
                 {...props}
                 className={newClassName}
               />
diff --git a/FrontEnd/src/components/button/FlatButton.tsx b/FrontEnd/src/components/button/FlatButton.tsx
index 9f074dd6..aad02e76 100644
--- a/FrontEnd/src/components/button/FlatButton.tsx
+++ b/FrontEnd/src/components/button/FlatButton.tsx
@@ -1,12 +1,12 @@
 import { ComponentPropsWithoutRef, Ref } from "react";
 import classNames from "classnames";
 
-import { Text, useC, ThemeColor } from "../common";
+import { Text, useC, ClickableColor } from "../common";
 
 import "./FlatButton.css";
 
 interface FlatButtonProps extends ComponentPropsWithoutRef<"button"> {
-  color?: ThemeColor;
+  color?: ClickableColor;
   text?: Text;
   buttonRef?: Ref<HTMLButtonElement> | null;
 }
diff --git a/FrontEnd/src/components/button/IconButton.tsx b/FrontEnd/src/components/button/IconButton.tsx
index 95c58887..e0862167 100644
--- a/FrontEnd/src/components/button/IconButton.tsx
+++ b/FrontEnd/src/components/button/IconButton.tsx
@@ -1,13 +1,13 @@
 import { ComponentPropsWithoutRef } from "react";
 import classNames from "classnames";
 
-import { ThemeColor } from "../common";
+import { ClickableColor } from "../common";
 
 import "./IconButton.css";
 
 interface IconButtonProps extends ComponentPropsWithoutRef<"i"> {
   icon: string;
-  color?: ThemeColor | "grayscale";
+  color?: ClickableColor;
   large?: boolean;
   disabled?: boolean; // TODO: Not implemented
 }
diff --git a/FrontEnd/src/components/button/LoadingButton.tsx b/FrontEnd/src/components/button/LoadingButton.tsx
index d9d41ddb..9d65a2b3 100644
--- a/FrontEnd/src/components/button/LoadingButton.tsx
+++ b/FrontEnd/src/components/button/LoadingButton.tsx
@@ -1,12 +1,12 @@
 import classNames from "classnames";
 
-import { I18nText, ThemeColor, useC } from "../common";
+import { I18nText, ClickableColor, useC } from "../common";
 import Spinner from "../Spinner";
 
 import "./LoadingButton.css";
 
 interface LoadingButtonProps extends React.ComponentPropsWithoutRef<"button"> {
-  color?: ThemeColor;
+  color?: ClickableColor;
   text?: I18nText;
   loading?: boolean;
 }
diff --git a/FrontEnd/src/components/common.ts b/FrontEnd/src/components/common.ts
index 835a8b4a..48473269 100644
--- a/FrontEnd/src/components/common.ts
+++ b/FrontEnd/src/components/common.ts
@@ -12,6 +12,8 @@ export const themeColors = [
 
 export type ThemeColor = (typeof themeColors)[number];
 
+export type ClickableColor = ThemeColor | "grayscale" | "minor";
+
 export { breakpoints } from "./breakpoints";
 
 export * as geometry from "~src/utilities/geometry";
diff --git a/FrontEnd/src/components/dialog/FullPageDialog.tsx b/FrontEnd/src/components/dialog/FullPageDialog.tsx
index 6368fc0a..cba57e21 100644
--- a/FrontEnd/src/components/dialog/FullPageDialog.tsx
+++ b/FrontEnd/src/components/dialog/FullPageDialog.tsx
@@ -38,7 +38,7 @@ const FullPageDialog: React.FC<FullPageDialogProps> = ({
         <div
           className={classnames(
             "cru-full-page-content-container",
-            contentContainerClassName
+            contentContainerClassName,
           )}
         >
           {children}
@@ -46,7 +46,7 @@ const FullPageDialog: React.FC<FullPageDialogProps> = ({
       </div>
     </CSSTransition>,
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-    document.getElementById("portal")!
+    document.getElementById("portal")!,
   );
 };
 
diff --git a/FrontEnd/src/components/dialog/OperationDialog.tsx b/FrontEnd/src/components/dialog/OperationDialog.tsx
index 4b4ceb36..6ca4d0a0 100644
--- a/FrontEnd/src/components/dialog/OperationDialog.tsx
+++ b/FrontEnd/src/components/dialog/OperationDialog.tsx
@@ -8,7 +8,7 @@ import {
   Initializer as InputInitializer,
   InputConfirmValueDict,
 } from "../input";
-import { ButtonRow } from "../button";
+import { ButtonRowV2 } from "../button";
 import Dialog from "./Dialog";
 import DialogContainer from "./DialogContainer";
 import { useDialogController } from "./DialogProvider";
@@ -140,7 +140,7 @@ function OperationDialog<TData>(props: OperationDialogProps<TData>) {
   }
 
   let body: ReactNode;
-  let buttons: ComponentProps<typeof ButtonRow>["buttons"];
+  let buttons: ComponentProps<typeof ButtonRowV2>["buttons"];
 
   if (step.type === "input" || step.type === "process") {
     const isProcessing = step.type === "process";
@@ -161,25 +161,19 @@ function OperationDialog<TData>(props: OperationDialogProps<TData>) {
     buttons = [
       {
         key: "cancel",
-        type: "normal",
-        props: {
-          text: "operationDialog.cancel",
-          color: "secondary",
-          outline: true,
-          onClick: close,
-          disabled: isProcessing,
-        },
+        text: "operationDialog.cancel",
+        onClick: close,
+        disabled: isProcessing,
       },
       {
         key: "confirm",
         type: "loading",
-        props: {
-          text: "operationDialog.confirm",
-          color,
-          loading: isProcessing,
-          disabled: hasErrorAndDirty,
-          onClick: onConfirm,
-        },
+        action: "major",
+        text: "operationDialog.confirm",
+        color,
+        loading: isProcessing,
+        disabled: hasErrorAndDirty,
+        onClick: onConfirm,
       },
     ];
   } else {
@@ -207,18 +201,17 @@ function OperationDialog<TData>(props: OperationDialogProps<TData>) {
       {
         key: "ok",
         type: "normal",
-        props: {
-          text: "operationDialog.ok",
-          color: "primary",
-          onClick: close,
-        },
+        action: "major",
+        color: "create",
+        text: "operationDialog.ok",
+        onClick: close,
       },
     ];
   }
 
   return (
     <Dialog color={color}>
-      <DialogContainer title={title} titleColor={color} buttons={buttons}>
+      <DialogContainer title={title} titleColor={color} buttonsV2={buttons}>
         {body}
       </DialogContainer>
     </Dialog>
diff --git a/FrontEnd/src/components/index.css b/FrontEnd/src/components/index.css
index a8f5e9a5..eb1f5b46 100644
--- a/FrontEnd/src/components/index.css
+++ b/FrontEnd/src/components/index.css
@@ -9,7 +9,7 @@
 body {
   font-family: var(--cru-default-font-family);
   background: var(--cru-body-background-color);
-  color: var(--cru-text-primary-color);
+  color: var(--cru-text-major-color);
   line-height: 1.2;
 }
 
diff --git a/FrontEnd/src/components/theme.css b/FrontEnd/src/components/theme.css
index 67340b6f..bd7805cf 100644
--- a/FrontEnd/src/components/theme.css
+++ b/FrontEnd/src/components/theme.css
@@ -34,16 +34,16 @@
 :root {
   --cru-background-color: hsl(0 0% 100%);
   --cru-container-background-color: hsl(0 0% 97%);
-  --cru-text-primary-color: hsl(0 0% 0%);
-  --cru-text-secondary-color: hsl(0 0% 38%);
+  --cru-text-major-color: hsl(0 0% 0%);
+  --cru-text-minor-color: hsl(0 0% 38%);
 }
 
 @media (prefers-color-scheme: dark) {
   :root {
     --cru-background-color: hsl(0 0% 0%);
     --cru-container-background-color: hsl(0 0% 2%);
-    --cru-text-primary-color: hsl(0 0% 100%);
-    --cru-text-secondary-color: hsl(0 0% 85%);
+    --cru-text-major-color: hsl(0 0% 100%);
+    --cru-text-minor-color: hsl(0 0% 85%);
   }
 }
 
@@ -87,11 +87,19 @@
   --cru-clickable-grayscale-hover-color: hsl(0 0% 92%);
   --cru-clickable-grayscale-focus-color: hsl(0 0% 92%);
   --cru-clickable-grayscale-active-color: hsl(0 0% 88%);
+  --cru-clickable-minor-normal-color: hsl(0 0% 30%);
+  --cru-clickable-minor-hover-color: hsl(0 0% 40%);
+  --cru-clickable-minor-focus-color: hsl(0 0% 40%);
+  --cru-clickable-minor-active-color: hsl(0 0% 45%);
   --cru-clickable-disabled-color: hsl(0 0% 50%);
 }
 
 @media (prefers-color-scheme: dark) {
   :root {
+    --cru-clickable-minor-normal-color: hsl(0 0% 74%);
+    --cru-clickable-minor-hover-color: hsl(0 0% 82%);
+    --cru-clickable-minor-focus-color: hsl(0 0% 82%);
+    --cru-clickable-minor-active-color: hsl(0 0% 90%);
     --cru-clickable-grayscale-normal-color: hsl(0 0% 0%);
     --cru-clickable-grayscale-hover-color: hsl(0 0% 10%);
     --cru-clickable-grayscale-focus-color: hsl(0 0% 10%);
@@ -134,6 +142,13 @@
   --cru-clickable-active-color: var(--cru-clickable-grayscale-active-color);
 }
 
+.cru-clickable-minor {
+  --cru-clickable-normal-color: var(--cru-clickable-minor-normal-color);
+  --cru-clickable-hover-color: var(--cru-clickable-minor-hover-color);
+  --cru-clickable-focus-color: var(--cru-clickable-minor-focus-color);
+  --cru-clickable-active-color: var(--cru-clickable-minor-active-color);
+}
+
 /* button colors */
 :root {
   /* push button colors */
diff --git a/FrontEnd/src/pages/setting/index.css b/FrontEnd/src/pages/setting/index.css
index a8a44524..19e7cff4 100644
--- a/FrontEnd/src/pages/setting/index.css
+++ b/FrontEnd/src/pages/setting/index.css
@@ -38,7 +38,7 @@
 }
 
 .setting-item-label-sub {
-  color: var(--cru-text-secondary-color);
+  color: var(--cru-text-minor-color);
 }
 
 .setting-item-value-area {
@@ -50,7 +50,7 @@
 }
 
 .register-code {
-  background: var(--cru-text-primary-color);
+  background: var(--cru-text-major-color);
   color: var(--cru-background-color);
   border-radius: 3px;
   padding: 0.2em;
diff --git a/FrontEnd/src/pages/timeline/TimelineCard.css b/FrontEnd/src/pages/timeline/TimelineCard.css
index ab1a1fd0..29e59b62 100644
--- a/FrontEnd/src/pages/timeline/TimelineCard.css
+++ b/FrontEnd/src/pages/timeline/TimelineCard.css
@@ -17,13 +17,13 @@
 .timeline-card-title {
   display: inline-block;
   vertical-align: middle;
-  color: var(--cru-text-primary-color);
+  color: var(--cru-text-major-color);
   margin: 0.5em 1em;
 }
 
 .timeline-card-title-name {
   margin-inline-start: 1em;
-  color: var(--cru-text-secondary-color);
+  color: var(--cru-text-minor-color);
 }
 
 .timeline-card-user {
diff --git a/FrontEnd/src/pages/timeline/TimelinePostCreateView.css b/FrontEnd/src/pages/timeline/TimelinePostCreateView.css
index 5986e566..252340f6 100644
--- a/FrontEnd/src/pages/timeline/TimelinePostCreateView.css
+++ b/FrontEnd/src/pages/timeline/TimelinePostCreateView.css
@@ -17,8 +17,8 @@
   width: 100%;
   height: 100%;
   background-color: var(--cru-background-color);
-  color: var(--cru-text-primary-color);
-  border: 1px solid var(--cru-text-primary-color);
+  color: var(--cru-text-major-color);
+  border: 1px solid var(--cru-text-major-color);
   padding: 0.5em;
   border-radius: 5px;
 }
-- 
cgit v1.2.3

