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/Dialog.tsx | 63 +++++++++++++++++++++++++++++++
 1 file changed, 63 insertions(+)
 create mode 100644 FrontEnd/src/components/dialog/Dialog.tsx

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

diff --git a/FrontEnd/src/components/dialog/Dialog.tsx b/FrontEnd/src/components/dialog/Dialog.tsx
new file mode 100644
index 00000000..2ff7bea8
--- /dev/null
+++ b/FrontEnd/src/components/dialog/Dialog.tsx
@@ -0,0 +1,63 @@
+import { ReactNode, useRef } from "react";
+import ReactDOM from "react-dom";
+import { CSSTransition } from "react-transition-group";
+import classNames from "classnames";
+
+import { ThemeColor } from "../common";
+
+import "./Dialog.css";
+
+const optionalPortalElement = document.getElementById("portal");
+if (optionalPortalElement == null) {
+  throw new Error("Portal element not found");
+}
+const portalElement = optionalPortalElement;
+
+interface DialogProps {
+  open: boolean;
+  onClose: () => void;
+  color?: ThemeColor;
+  children?: ReactNode;
+  disableCloseOnClickOnOverlay?: boolean;
+}
+
+export default function Dialog({
+  open,
+  onClose,
+  color,
+  children,
+  disableCloseOnClickOnOverlay,
+}: DialogProps) {
+  color = color ?? "primary";
+
+  const nodeRef = useRef(null);
+
+  return ReactDOM.createPortal(
+    <CSSTransition
+      nodeRef={nodeRef}
+      mountOnEnter
+      unmountOnExit
+      in={open}
+      timeout={300}
+      classNames="cru-dialog"
+    >
+      <div
+        ref={nodeRef}
+        className={classNames("cru-dialog-overlay", `cru-${color}`)}
+      >
+        <div
+          className="cru-dialog-background"
+          onClick={
+            disableCloseOnClickOnOverlay
+              ? undefined
+              : () => {
+                  onClose();
+                }
+          }
+        />
+        <div className="cru-dialog-container">{children}</div>
+      </div>
+    </CSSTransition>,
+    portalElement,
+  );
+}
-- 
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/Dialog.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 d65dcebc3ed64c96c70f0ee7f228b4dfe79b28a1 Mon Sep 17 00:00:00 2001
From: crupest <crupest@outlook.com>
Date: Tue, 29 Aug 2023 20:41:34 +0800
Subject: ...

---
 FrontEnd/src/components/common.ts                  |   2 +
 FrontEnd/src/components/dialog/Dialog.css          |   4 +-
 FrontEnd/src/components/dialog/Dialog.tsx          |  39 ++---
 FrontEnd/src/components/theme-color.css            | 173 ---------------------
 FrontEnd/src/components/theme.css                  | 100 ++++++------
 FrontEnd/src/index.css                             |   3 -
 .../src/pages/setting/ChangeNicknameDialog.tsx     |   2 +-
 FrontEnd/src/pages/setting/index.tsx               |   4 +-
 FrontEnd/src/utilities/index.ts                    |  10 ++
 9 files changed, 88 insertions(+), 249 deletions(-)
 delete mode 100644 FrontEnd/src/components/theme-color.css
 create mode 100644 FrontEnd/src/utilities/index.ts

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

diff --git a/FrontEnd/src/components/common.ts b/FrontEnd/src/components/common.ts
index 171ebc48..835a8b4a 100644
--- a/FrontEnd/src/components/common.ts
+++ b/FrontEnd/src/components/common.ts
@@ -1,3 +1,5 @@
+import "./index.css";
+
 export type { Text, I18nText } from "~src/common";
 export { UiLogicError, c, convertI18nText, useC } from "~src/common";
 
diff --git a/FrontEnd/src/components/dialog/Dialog.css b/FrontEnd/src/components/dialog/Dialog.css
index e4c61440..f25309ae 100644
--- a/FrontEnd/src/components/dialog/Dialog.css
+++ b/FrontEnd/src/components/dialog/Dialog.css
@@ -17,7 +17,7 @@
   right: 0;
   top: 0;
   bottom: 0;
-  background-color: var(--cru-surface-dim-color);
+  background-color: var(--cru-dialog-overlay-color);
   opacity: 0.8;
 }
 
@@ -27,7 +27,7 @@
 
   margin: 2em auto;
 
-  border: var(--cru-key-container-color) 1px solid;
+  border: var(--cru-dialog-container-background-color) 1px solid;
   border-radius: 5px;
   padding: 1.5em;
   background-color: var(--cru-surface-color);
diff --git a/FrontEnd/src/components/dialog/Dialog.tsx b/FrontEnd/src/components/dialog/Dialog.tsx
index b1d66704..bdba9198 100644
--- a/FrontEnd/src/components/dialog/Dialog.tsx
+++ b/FrontEnd/src/components/dialog/Dialog.tsx
@@ -1,6 +1,5 @@
 import { ReactNode, useRef } from "react";
 import ReactDOM from "react-dom";
-import { CSSTransition } from "react-transition-group";
 import classNames from "classnames";
 
 import { ThemeColor } from "../common";
@@ -16,42 +15,34 @@ if (optionalPortalElement == null) {
 const portalElement = optionalPortalElement;
 
 interface DialogProps {
-  color?: ThemeColor;
   children?: ReactNode;
   disableCloseOnClickOnOverlay?: boolean;
 }
 
 export default function Dialog({
-  color,
   children,
   disableCloseOnClickOnOverlay,
 }: DialogProps) {
-  color = color ?? "primary";
-
   const closeDialog = useCloseDialog();
 
-  const nodeRef = useRef(null);
+  const lastPointerDownIdRef = useRef<number | null>(null);
 
   return ReactDOM.createPortal(
-    <CSSTransition
-      nodeRef={nodeRef}
-      mountOnEnter
-      unmountOnExit
-      in
-      timeout={300}
-      classNames="cru-dialog"
-    >
+    <div className={classNames("cru-dialog-overlay")}>
       <div
-        ref={nodeRef}
-        className={classNames("cru-dialog-overlay", `cru-${color}`)}
-      >
-        <div
-          className="cru-dialog-background"
-          onClick={disableCloseOnClickOnOverlay ? undefined : closeDialog}
-        />
-        <div className="cru-dialog-container">{children}</div>
-      </div>
-    </CSSTransition>,
+        className="cru-dialog-background"
+        onPointerDown={(e) => {
+          lastPointerDownIdRef.current = e.pointerId;
+        }}
+        onPointerUp={(e) => {
+          if (lastPointerDownIdRef.current === e.pointerId) {
+            if (!disableCloseOnClickOnOverlay) closeDialog();
+          }
+          lastPointerDownIdRef.current = null;
+        }}
+      />
+      <div className="cru-dialog-container">{children}</div>
+    </div>,
     portalElement,
   );
 }
diff --git a/FrontEnd/src/components/theme-color.css b/FrontEnd/src/components/theme-color.css
deleted file mode 100644
index 24a7e267..00000000
--- a/FrontEnd/src/components/theme-color.css
+++ /dev/null
@@ -1,173 +0,0 @@
-/* Generated by theme-generator.ts */
-
-:root {
-  --cru-primary-color: hsl(210 100% 40%);
-  --cru-primary-1-color: hsl(210 100% 37%);
-  --cru-primary-2-color: hsl(210 100% 34%);
-  --cru-primary-on-color: hsl(210 100% 100%);
-  --cru-primary-container-color: hsl(210 100% 90%);
-  --cru-primary-container-1-color: hsl(210 100% 80%);
-  --cru-primary-container-2-color: hsl(210 100% 70%);
-  --cru-primary-on-container-color: hsl(210 100% 10%);
-  --cru-secondary-color: hsl(40 100% 40%);
-  --cru-secondary-1-color: hsl(40 100% 37%);
-  --cru-secondary-2-color: hsl(40 100% 34%);
-  --cru-secondary-on-color: hsl(40 100% 100%);
-  --cru-secondary-container-color: hsl(40 100% 90%);
-  --cru-secondary-container-1-color: hsl(40 100% 80%);
-  --cru-secondary-container-2-color: hsl(40 100% 70%);
-  --cru-secondary-on-container-color: hsl(40 100% 10%);
-  --cru-tertiary-color: hsl(160 100% 40%);
-  --cru-tertiary-1-color: hsl(160 100% 37%);
-  --cru-tertiary-2-color: hsl(160 100% 34%);
-  --cru-tertiary-on-color: hsl(160 100% 100%);
-  --cru-tertiary-container-color: hsl(160 100% 90%);
-  --cru-tertiary-container-1-color: hsl(160 100% 80%);
-  --cru-tertiary-container-2-color: hsl(160 100% 70%);
-  --cru-tertiary-on-container-color: hsl(160 100% 10%);
-  --cru-danger-color: hsl(0 100% 40%);
-  --cru-danger-1-color: hsl(0 100% 37%);
-  --cru-danger-2-color: hsl(0 100% 34%);
-  --cru-danger-on-color: hsl(0 100% 100%);
-  --cru-danger-container-color: hsl(0 100% 90%);
-  --cru-danger-container-1-color: hsl(0 100% 80%);
-  --cru-danger-container-2-color: hsl(0 100% 70%);
-  --cru-danger-on-container-color: hsl(0 100% 10%);
-  --cru-success-color: hsl(120 60% 40%);
-  --cru-success-1-color: hsl(120 60% 37%);
-  --cru-success-2-color: hsl(120 60% 34%);
-  --cru-success-on-color: hsl(120 60% 100%);
-  --cru-success-container-color: hsl(120 60% 90%);
-  --cru-success-container-1-color: hsl(120 60% 80%);
-  --cru-success-container-2-color: hsl(120 60% 70%);
-  --cru-success-on-container-color: hsl(120 60% 10%);
-  --cru-surface-dim-color: hsl(0 0% 87%);
-  --cru-surface-color: hsl(0 0% 98%);
-  --cru-surface-1-color: hsl(0 0% 90%);
-  --cru-surface-2-color: hsl(0 0% 82%);
-  --cru-surface-bright-color: hsl(0 0% 98%);
-  --cru-surface-container-lowest-color: hsl(0 0% 100%);
-  --cru-surface-container-low-color: hsl(0 0% 96%);
-  --cru-surface-container-color: hsl(0 0% 94%);
-  --cru-surface-container-high-color: hsl(0 0% 92%);
-  --cru-surface-container-highest-color: hsl(0 0% 90%);
-  --cru-surface-on-color: hsl(0 0% 10%);
-  --cru-surface-on-variant-color: hsl(0 0% 30%);
-  --cru-surface-outline-color: hsl(0 0% 50%);
-  --cru-surface-outline-variant-color: hsl(0 0% 80%);
-}
-
-@media (prefers-color-scheme: dark) {
-  :root {
-    --cru-primary-color: hsl(210 100% 80%);
-    --cru-primary-1-color: hsl(210 100% 75%);
-    --cru-primary-2-color: hsl(210 100% 68%);
-    --cru-primary-on-color: hsl(210 100% 20%);
-    --cru-primary-container-color: hsl(210 100% 30%);
-    --cru-primary-container-1-color: hsl(210 100% 25%);
-    --cru-primary-container-2-color: hsl(210 100% 20%);
-    --cru-primary-on-container-color: hsl(210 100% 90%);
-    --cru-secondary-color: hsl(40 100% 80%);
-    --cru-secondary-1-color: hsl(40 100% 75%);
-    --cru-secondary-2-color: hsl(40 100% 68%);
-    --cru-secondary-on-color: hsl(40 100% 20%);
-    --cru-secondary-container-color: hsl(40 100% 30%);
-    --cru-secondary-container-1-color: hsl(40 100% 25%);
-    --cru-secondary-container-2-color: hsl(40 100% 20%);
-    --cru-secondary-on-container-color: hsl(40 100% 90%);
-    --cru-tertiary-color: hsl(160 100% 80%);
-    --cru-tertiary-1-color: hsl(160 100% 75%);
-    --cru-tertiary-2-color: hsl(160 100% 68%);
-    --cru-tertiary-on-color: hsl(160 100% 20%);
-    --cru-tertiary-container-color: hsl(160 100% 30%);
-    --cru-tertiary-container-1-color: hsl(160 100% 25%);
-    --cru-tertiary-container-2-color: hsl(160 100% 20%);
-    --cru-tertiary-on-container-color: hsl(160 100% 90%);
-    --cru-danger-color: hsl(0 100% 80%);
-    --cru-danger-1-color: hsl(0 100% 75%);
-    --cru-danger-2-color: hsl(0 100% 68%);
-    --cru-danger-on-color: hsl(0 100% 20%);
-    --cru-danger-container-color: hsl(0 100% 30%);
-    --cru-danger-container-1-color: hsl(0 100% 25%);
-    --cru-danger-container-2-color: hsl(0 100% 20%);
-    --cru-danger-on-container-color: hsl(0 100% 90%);
-    --cru-success-color: hsl(120 60% 80%);
-    --cru-success-1-color: hsl(120 60% 75%);
-    --cru-success-2-color: hsl(120 60% 68%);
-    --cru-success-on-color: hsl(120 60% 20%);
-    --cru-success-container-color: hsl(120 60% 30%);
-    --cru-success-container-1-color: hsl(120 60% 25%);
-    --cru-success-container-2-color: hsl(120 60% 20%);
-    --cru-success-on-container-color: hsl(120 60% 90%);
-    --cru-surface-dim-color: hsl(0 0% 6%);
-    --cru-surface-color: hsl(0 0% 6%);
-    --cru-surface-1-color: hsl(0 0% 25%);
-    --cru-surface-2-color: hsl(0 0% 40%);
-    --cru-surface-bright-color: hsl(0 0% 24%);
-    --cru-surface-container-lowest-color: hsl(0 0% 4%);
-    --cru-surface-container-low-color: hsl(0 0% 10%);
-    --cru-surface-container-color: hsl(0 0% 12%);
-    --cru-surface-container-high-color: hsl(0 0% 17%);
-    --cru-surface-container-highest-color: hsl(0 0% 22%);
-    --cru-surface-on-color: hsl(0 0% 90%);
-    --cru-surface-on-variant-color: hsl(0 0% 80%);
-    --cru-surface-outline-color: hsl(0 0% 60%);
-    --cru-surface-outline-variant-color: hsl(0 0% 30%);
-  }
-}
-
-.cru-primary {
-  --cru-key-color: var(--cru-primary-color);
-  --cru-key-1-color: var(--cru-primary-1-color);
-  --cru-key-2-color: var(--cru-primary-2-color);
-  --cru-key-on-color: var(--cru-primary-on-color);
-  --cru-key-container-color: var(--cru-primary-container-color);
-  --cru-key-container-1-color: var(--cru-primary-container-1-color);
-  --cru-key-container-2-color: var(--cru-primary-container-2-color);
-  --cru-key-on-container-color: var(--cru-primary-on-container-color);
-}
-
-.cru-secondary {
-  --cru-key-color: var(--cru-secondary-color);
-  --cru-key-1-color: var(--cru-secondary-1-color);
-  --cru-key-2-color: var(--cru-secondary-2-color);
-  --cru-key-on-color: var(--cru-secondary-on-color);
-  --cru-key-container-color: var(--cru-secondary-container-color);
-  --cru-key-container-1-color: var(--cru-secondary-container-1-color);
-  --cru-key-container-2-color: var(--cru-secondary-container-2-color);
-  --cru-key-on-container-color: var(--cru-secondary-on-container-color);
-}
-
-.cru-tertiary {
-  --cru-key-color: var(--cru-tertiary-color);
-  --cru-key-1-color: var(--cru-tertiary-1-color);
-  --cru-key-2-color: var(--cru-tertiary-2-color);
-  --cru-key-on-color: var(--cru-tertiary-on-color);
-  --cru-key-container-color: var(--cru-tertiary-container-color);
-  --cru-key-container-1-color: var(--cru-tertiary-container-1-color);
-  --cru-key-container-2-color: var(--cru-tertiary-container-2-color);
-  --cru-key-on-container-color: var(--cru-tertiary-on-container-color);
-}
-
-.cru-danger {
-  --cru-key-color: var(--cru-danger-color);
-  --cru-key-1-color: var(--cru-danger-1-color);
-  --cru-key-2-color: var(--cru-danger-2-color);
-  --cru-key-on-color: var(--cru-danger-on-color);
-  --cru-key-container-color: var(--cru-danger-container-color);
-  --cru-key-container-1-color: var(--cru-danger-container-1-color);
-  --cru-key-container-2-color: var(--cru-danger-container-2-color);
-  --cru-key-on-container-color: var(--cru-danger-on-container-color);
-}
-
-.cru-success {
-  --cru-key-color: var(--cru-success-color);
-  --cru-key-1-color: var(--cru-success-1-color);
-  --cru-key-2-color: var(--cru-success-2-color);
-  --cru-key-on-color: var(--cru-success-on-color);
-  --cru-key-container-color: var(--cru-success-container-color);
-  --cru-key-container-1-color: var(--cru-success-container-1-color);
-  --cru-key-container-2-color: var(--cru-success-container-2-color);
-  --cru-key-on-container-color: var(--cru-success-on-container-color);
-}
-
diff --git a/FrontEnd/src/components/theme.css b/FrontEnd/src/components/theme.css
index 6ceb369f..d7e30d1a 100644
--- a/FrontEnd/src/components/theme.css
+++ b/FrontEnd/src/components/theme.css
@@ -1,5 +1,3 @@
-@import "./theme-color.css";
-
 :root {
   --cru-default-font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
   --cru-page-padding: 1em 2em;
@@ -10,26 +8,26 @@
 
 /* theme colors */
 :root {
-  --cru-primary-color: hsl(210, 100%, 50%);
-  --cru-secondary-color: hsl(30, 100%, 50%);
-  --cru-create-color: hsl(120, 100%, 25%);
-  --cru-danger-color: hsl(0, 100%, 50%);
+  --cru-primary-color: hsl(210 100% 50%);
+  --cru-secondary-color: hsl(30 100% 50%);
+  --cru-create-color: hsl(120 100% 25%);
+  --cru-danger-color: hsl(0 100% 50%);
 }
 
 /* common colors */
 :root {
-  --cru-background-color: hsl(0, 0%, 100%);
-  --cru-container-background-color: hsl(0, 0%, 97%);
-  --cru-text-primary-color: hsl(0, 0%, 0%);
-  --cru-text-secondary-color: hsl(0, 0%, 38%);
+  --cru-background-color: hsl(0 0% 100%);
+  --cru-container-background-color: hsl(0 0% 97%);
+  --cru-text-primary-color: hsl(0 0% 0%);
+  --cru-text-secondary-color: hsl(0 0% 38%);
 }
 
 @media (prefers-color-scheme: dark) {
   :root {
-    --cru-background-color: hsl(0, 0%, 0%);
-    --cru-container-background-color: hsl(0, 0%, 2%);
-    --cru-text-primary-color: hsl(0, 0%, 100%);
-    --cru-text-secondary-color: hsl(0, 0%, 85%);
+    --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%);
   }
 }
 
@@ -37,37 +35,51 @@
   --cru-body-background-color: var(--cru-background-color);
 }
 
+/* dialog color */
+
+:root {
+  --cru-dialog-overlay-color: hsl(0 0% 100%);
+  --cru-dialog-container-background-color: hsl(0 0% 100%);
+}
+
+@media (prefers-color-scheme: dark) {
+  :root {
+    --cru-dialog-overlay-color: hsl(0 0% 0%);
+    --cru-dialog-container-background-color: hsl(0 0% 0%);
+  }
+}
+
 /* clickable color */
 :root {
   --cru-clickable-primary-normal-color: var(--cru-primary-color);
-  --cru-clickable-primary-hover-color: hsl(210, 100%, 60%);
-  --cru-clickable-primary-focus-color: hsl(210, 100%, 60%);
-  --cru-clickable-primary-active-color: hsl(210, 100%, 70%);
+  --cru-clickable-primary-hover-color: hsl(210 100% 60%);
+  --cru-clickable-primary-focus-color: hsl(210 100% 60%);
+  --cru-clickable-primary-active-color: hsl(210 100% 70%);
   --cru-clickable-secondary-normal-color: var(--cru-secondary-color);
-  --cru-clickable-secondary-hover-color: hsl(30, 100%, 60%);
-  --cru-clickable-secondary-focus-color: hsl(30, 100%, 60%);
-  --cru-clickable-secondary-active-color: hsl(30, 100%, 70%);
+  --cru-clickable-secondary-hover-color: hsl(30 100% 60%);
+  --cru-clickable-secondary-focus-color: hsl(30 100% 60%);
+  --cru-clickable-secondary-active-color: hsl(30 100% 70%);
   --cru-clickable-create-normal-color: var(--cru-create-color);
-  --cru-clickable-create-hover-color: hsl(120, 100%, 35%);
-  --cru-clickable-create-focus-color: hsl(120, 100%, 35%);
-  --cru-clickable-create-active-color: hsl(120, 100%, 35%);
+  --cru-clickable-create-hover-color: hsl(120 100% 35%);
+  --cru-clickable-create-focus-color: hsl(120 100% 35%);
+  --cru-clickable-create-active-color: hsl(120 100% 35%);
   --cru-clickable-danger-normal-color: var(--cru-danger-color);
-  --cru-clickable-danger-hover-color: hsl(0, 100%, 60%);
-  --cru-clickable-danger-focus-color: hsl(0, 100%, 60%);
-  --cru-clickable-danger-active-color: hsl(0, 100%, 70%);
-  --cru-clickable-grayscale-normal-color: hsl(0, 0%, 100%);
-  --cru-clickable-grayscale-hover-color: hsl(0, 0%, 92%);
-  --cru-clickable-grayscale-focus-color: hsl(0, 0%, 92%);
-  --cru-clickable-grayscale-active-color: hsl(0, 0%, 88%);
-  --cru-clickable-disabled-color: hsl(0, 0%, 50%);
+  --cru-clickable-danger-hover-color: hsl(0 100% 60%);
+  --cru-clickable-danger-focus-color: hsl(0 100% 60%);
+  --cru-clickable-danger-active-color: hsl(0 100% 70%);
+  --cru-clickable-grayscale-normal-color: hsl(0 0% 100%);
+  --cru-clickable-grayscale-hover-color: hsl(0 0% 92%);
+  --cru-clickable-grayscale-focus-color: hsl(0 0% 92%);
+  --cru-clickable-grayscale-active-color: hsl(0 0% 88%);
+  --cru-clickable-disabled-color: hsl(0 0% 50%);
 }
 
 @media (prefers-color-scheme: dark) {
   :root {
-    --cru-clickable-grayscale-normal-color: hsl(0, 0%, 0%);
-    --cru-clickable-grayscale-hover-color: hsl(0, 0%, 10%);
-    --cru-clickable-grayscale-focus-color: hsl(0, 0%, 10%);
-    --cru-clickable-grayscale-active-color: hsl(0, 0%, 20%);
+    --cru-clickable-grayscale-normal-color: hsl(0 0% 0%);
+    --cru-clickable-grayscale-hover-color: hsl(0 0% 10%);
+    --cru-clickable-grayscale-focus-color: hsl(0 0% 10%);
+    --cru-clickable-grayscale-active-color: hsl(0 0% 20%);
   }
 }
 
@@ -110,19 +122,19 @@
 :root {
   /* push button colors */
   --cru-push-button-text-color: #ffffff;
-  --cru-push-button-disabled-text-color: hsl(0, 0%, 80%);
+  --cru-push-button-disabled-text-color: hsl(0 0% 80%);
 }
 
 /* Card colors */
 :root {
-  --cru-card-background-primary-color: hsl(210, 100%, 50%);
-  --cru-card-border-primary-color: hsl(210, 100%, 50%);
-  --cru-card-background-secondary-color: hsl(30, 100%, 50%);
-  --cru-card-border-secondary-color: hsl(30, 100%, 50%);
-  --cru-card-background-create-color: hsl(120, 100%, 25%);
-  --cru-card-border-create-color: hsl(120, 100%, 25%);
-  --cru-card-background-danger-color: hsl(0, 100%, 50%);
-  --cru-card-border-danger-color: hsl(0, 100%, 50%);
+  --cru-card-background-primary-color: hsl(210 100% 50%);
+  --cru-card-border-primary-color: hsl(210 100% 50%);
+  --cru-card-background-secondary-color: hsl(30 100% 50%);
+  --cru-card-border-secondary-color: hsl(30 100% 50%);
+  --cru-card-background-create-color: hsl(120 100% 25%);
+  --cru-card-border-create-color: hsl(120 100% 25%);
+  --cru-card-background-danger-color: hsl(0 100% 50%);
+  --cru-card-border-danger-color: hsl(0 100% 50%);
 }
 
 .cru-card-primary {
diff --git a/FrontEnd/src/index.css b/FrontEnd/src/index.css
index ee92520b..f779297b 100644
--- a/FrontEnd/src/index.css
+++ b/FrontEnd/src/index.css
@@ -1,8 +1,5 @@
 @import "npm:bootstrap-icons/font/bootstrap-icons.css";
 
-@import "./views/common/index.css";
-
-
 small {
   line-height: 1.2;
 }
diff --git a/FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx b/FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx
index bd1eaa51..912f554f 100644
--- a/FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx
+++ b/FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx
@@ -1,7 +1,7 @@
 import { getHttpUserClient } from "~src/http/user";
 import { useUserLoggedIn } from "~src/services/user";
 
-import OperationDialog from "~src/components/dialog/OperationDialog";
+import { OperationDialog } from "~src/components/dialog";
 
 export default function ChangeNicknameDialog() {
   const user = useUserLoggedIn();
diff --git a/FrontEnd/src/pages/setting/index.tsx b/FrontEnd/src/pages/setting/index.tsx
index 4d2c28c7..d2333134 100644
--- a/FrontEnd/src/pages/setting/index.tsx
+++ b/FrontEnd/src/pages/setting/index.tsx
@@ -245,9 +245,9 @@ export default function SettingPage() {
   const navigate = useNavigate();
 
   const { controller, createDialogSwitch } = useDialog({
-    "change-password": <ChangeNicknameDialog />,
+    "change-nickname": <ChangeNicknameDialog />,
     "change-avatar": <ChangeAvatarDialog />,
-    "change-nickname": <ChangePasswordDialog />,
+    "change-password": <ChangePasswordDialog />,
     logout: (
       <ConfirmDialog
         title="settings.dialogConfirmLogout.title"
diff --git a/FrontEnd/src/utilities/index.ts b/FrontEnd/src/utilities/index.ts
new file mode 100644
index 00000000..085f8ae3
--- /dev/null
+++ b/FrontEnd/src/utilities/index.ts
@@ -0,0 +1,10 @@
+export { default as base64 } from "./base64";
+export { withQuery } from "./url";
+
+export function delay(milliseconds: number): Promise<void> {
+  return new Promise<void>((resolve) => {
+    setTimeout(() => {
+      resolve();
+    }, milliseconds);
+  });
+}
-- 
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/Dialog.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 6664fb3506b1ea4af712fa849bd7c761a06c9843 Mon Sep 17 00:00:00 2001
From: crupest <crupest@outlook.com>
Date: Thu, 31 Aug 2023 23:56:13 +0800
Subject: ...

---
 FrontEnd/src/components/dialog/Dialog.tsx         |   4 +-
 FrontEnd/src/components/dialog/FullPageDialog.css |  28 +--
 FrontEnd/src/components/dialog/FullPageDialog.tsx |  80 +++++----
 FrontEnd/src/components/hooks/responsive.ts       |   4 +-
 FrontEnd/src/pages/timeline/CollapseButton.tsx    |  25 ---
 FrontEnd/src/pages/timeline/Timeline.tsx          |   4 +-
 FrontEnd/src/pages/timeline/TimelineCard.css      |  63 -------
 FrontEnd/src/pages/timeline/TimelineCard.tsx      | 158 -----------------
 FrontEnd/src/pages/timeline/TimelineInfoCard.css  |  63 +++++++
 FrontEnd/src/pages/timeline/TimelineInfoCard.tsx  | 199 ++++++++++++++++++++++
 10 files changed, 314 insertions(+), 314 deletions(-)
 delete mode 100644 FrontEnd/src/pages/timeline/CollapseButton.tsx
 delete mode 100644 FrontEnd/src/pages/timeline/TimelineCard.css
 delete mode 100644 FrontEnd/src/pages/timeline/TimelineCard.tsx
 create mode 100644 FrontEnd/src/pages/timeline/TimelineInfoCard.css
 create mode 100644 FrontEnd/src/pages/timeline/TimelineInfoCard.tsx

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

diff --git a/FrontEnd/src/components/dialog/Dialog.tsx b/FrontEnd/src/components/dialog/Dialog.tsx
index 85e8ca46..043a8eec 100644
--- a/FrontEnd/src/components/dialog/Dialog.tsx
+++ b/FrontEnd/src/components/dialog/Dialog.tsx
@@ -2,7 +2,7 @@ import { ReactNode, useRef } from "react";
 import ReactDOM from "react-dom";
 import classNames from "classnames";
 
-import { ThemeColor } from "../common";
+import { ThemeColor, UiLogicError } from "../common";
 
 import { useCloseDialog } from "./DialogProvider";
 
@@ -10,7 +10,7 @@ import "./Dialog.css";
 
 const optionalPortalElement = document.getElementById("portal");
 if (optionalPortalElement == null) {
-  throw new Error("Portal element not found");
+  throw new UiLogicError();
 }
 const portalElement = optionalPortalElement;
 
diff --git a/FrontEnd/src/components/dialog/FullPageDialog.css b/FrontEnd/src/components/dialog/FullPageDialog.css
index 2f1fc636..ce07c6ac 100644
--- a/FrontEnd/src/components/dialog/FullPageDialog.css
+++ b/FrontEnd/src/components/dialog/FullPageDialog.css
@@ -1,44 +1,30 @@
-.cru-full-page {
+.cru-dialog-full-page {
   position: fixed;
   z-index: 1030;
   left: 0;
   top: 0;
   right: 0;
   bottom: 0;
-  background-color: white;
+  background-color: var(--cru-background-color);
   padding-top: 56px;
 }
 
-.cru-full-page-top-bar {
+.cru-dialog-full-page-top-bar {
   height: 56px;
   position: absolute;
   top: 0;
   left: 0;
   right: 0;
   z-index: 1;
-  background-color: var(--cru-primary-color);
+  background-color: var(--cru-theme-color);
   display: flex;
   align-items: center;
 }
 
-.cru-full-page-content-container {
+.cru-dialog-full-page-content-container {
   overflow: scroll;
 }
 
-.cru-full-page-back-button {
-  color: var(--cru-primary-t-color);
-}
-
-.cru-full-page-enter {
-  transform: translate(100%, 0);
-}
-
-.cru-full-page-enter-active {
-  transform: none;
-  transition: transform 0.3s;
-}
-
-.cru-full-page-exit-active {
-  transition: transform 0.3s;
-  transform: translate(100%, 0);
+.cru-dialog-full-page-back-button {
+  margin-left: 0.5em;
 }
diff --git a/FrontEnd/src/components/dialog/FullPageDialog.tsx b/FrontEnd/src/components/dialog/FullPageDialog.tsx
index cba57e21..d18bcf73 100644
--- a/FrontEnd/src/components/dialog/FullPageDialog.tsx
+++ b/FrontEnd/src/components/dialog/FullPageDialog.tsx
@@ -1,53 +1,51 @@
-import * as React from "react";
+import { ReactNode } from "react";
 import { createPortal } from "react-dom";
-import classnames from "classnames";
-import { CSSTransition } from "react-transition-group";
+import classNames from "classnames";
+
+import { ThemeColor, UiLogicError } from "../common";
+import { IconButton } from "../button";
+
+import { useCloseDialog } from "./DialogProvider";
 
 import "./FullPageDialog.css";
-import IconButton from "../button/IconButton";
 
-export interface FullPageDialogProps {
-  show: boolean;
-  onBack: () => void;
+const optionalPortalElement = document.getElementById("portal");
+if (optionalPortalElement == null) {
+  throw new UiLogicError();
+}
+const portalElement = optionalPortalElement;
+
+interface FullPageDialogProps {
+  color?: ThemeColor;
   contentContainerClassName?: string;
-  children: React.ReactNode;
+  children: ReactNode;
 }
 
-const FullPageDialog: React.FC<FullPageDialogProps> = ({
-  show,
-  onBack,
+export default function FullPageDialog({
+  color,
   children,
   contentContainerClassName,
-}) => {
+}: FullPageDialogProps) {
+  const closeDialog = useCloseDialog();
+
   return createPortal(
-    <CSSTransition
-      mountOnEnter
-      unmountOnExit
-      in={show}
-      timeout={300}
-      classNames="cru-full-page"
-    >
-      <div className="cru-full-page">
-        <div className="cru-full-page-top-bar">
-          <IconButton
-            icon="arrow-left"
-            className="ms-3 cru-full-page-back-button"
-            onClick={onBack}
-          />
-        </div>
-        <div
-          className={classnames(
-            "cru-full-page-content-container",
-            contentContainerClassName,
-          )}
-        >
-          {children}
-        </div>
+    <div className={`cru-dialog-full-page cru-theme-${color ?? "primary"}`}>
+      <div className="cru-dialog-full-page-top-bar">
+        <IconButton
+          icon="arrow-left"
+          className="cru-dialog-full-page-back-button"
+          onClick={closeDialog}
+        />
+      </div>
+      <div
+        className={classNames(
+          "cru-dialog-full-page-content-container",
+          contentContainerClassName,
+        )}
+      >
+        {children}
       </div>
-    </CSSTransition>,
-    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-    document.getElementById("portal")!,
+    </div>,
+    portalElement,
   );
-};
-
-export default FullPageDialog;
+}
diff --git a/FrontEnd/src/components/hooks/responsive.ts b/FrontEnd/src/components/hooks/responsive.ts
index 6bcce96c..42c134ef 100644
--- a/FrontEnd/src/components/hooks/responsive.ts
+++ b/FrontEnd/src/components/hooks/responsive.ts
@@ -2,6 +2,6 @@ import { useMediaQuery } from "react-responsive";
 
 import { breakpoints } from "../breakpoints";
 
-export function useMobile(): boolean {
-  return useMediaQuery({ maxWidth: breakpoints.sm });
+export function useMobile(onChange?: (mobile: boolean) => void): boolean {
+  return useMediaQuery({ maxWidth: breakpoints.sm }, undefined, onChange);
 }
diff --git a/FrontEnd/src/pages/timeline/CollapseButton.tsx b/FrontEnd/src/pages/timeline/CollapseButton.tsx
deleted file mode 100644
index 1c4fa2ba..00000000
--- a/FrontEnd/src/pages/timeline/CollapseButton.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import { CSSProperties } from "react";
-
-import IconButton from "~src/components/button/IconButton";
-
-export default function CollapseButton({
-  collapse,
-  onClick,
-  className,
-  style,
-}: {
-  collapse: boolean;
-  onClick: () => void;
-  className?: string;
-  style?: CSSProperties;
-}) {
-  return (
-    <IconButton
-      color="primary"
-      icon={collapse ? "arrows-angle-expand" : "arrows-angle-contract"}
-      onClick={onClick}
-      className={className}
-      style={style}
-    />
-  );
-}
diff --git a/FrontEnd/src/pages/timeline/Timeline.tsx b/FrontEnd/src/pages/timeline/Timeline.tsx
index caf4f502..4fe5c521 100644
--- a/FrontEnd/src/pages/timeline/Timeline.tsx
+++ b/FrontEnd/src/pages/timeline/Timeline.tsx
@@ -19,7 +19,7 @@ import { useScrollToBottom } from "~src/components/hooks";
 
 import TimelinePostList from "./TimelinePostList";
 import TimelinePostEdit from "./TimelinePostCreateView";
-import TimelineCard from "./TimelineCard";
+import TimelineInfoCard from "./TimelineInfoCard";
 
 import "./Timeline.css";
 
@@ -159,7 +159,7 @@ export function Timeline(props: TimelineProps) {
   return (
     <div className="timeline-container">
       {timeline && (
-        <TimelineCard
+        <TimelineInfoCard
           timeline={timeline}
           connectionStatus={signalrState}
           onReload={updateTimeline}
diff --git a/FrontEnd/src/pages/timeline/TimelineCard.css b/FrontEnd/src/pages/timeline/TimelineCard.css
deleted file mode 100644
index 29e59b62..00000000
--- a/FrontEnd/src/pages/timeline/TimelineCard.css
+++ /dev/null
@@ -1,63 +0,0 @@
-.timeline-card {
-  position: fixed;
-  z-index: 1029;
-  top: 56px;
-  right: 0;
-  margin: 0.5em;
-  padding: 0.5em;
-  box-shadow: var(--timeline-card-shadow);
-}
-
-@media (min-width: 576px) {
-  .timeline-card-expand {
-    min-width: 400px;
-  }
-}
-
-.timeline-card-title {
-  display: inline-block;
-  vertical-align: middle;
-  color: var(--cru-text-major-color);
-  margin: 0.5em 1em;
-}
-
-.timeline-card-title-name {
-  margin-inline-start: 1em;
-  color: var(--cru-text-minor-color);
-}
-
-.timeline-card-user {
-  display: flex;
-  align-items: center;
-  margin: 0 1em 0.5em;
-}
-
-.timeline-card-user-avatar {
-  width: 2em;
-  height: 2em;
-  border-radius: 50%;
-}
-
-.timeline-card-user-nickname {
-  margin-inline: 0.6em;
-}
-
-.timeline-card-description {
-  margin: 0 1em 0.5em;
-}
-
-.timeline-card-top-right-area {
-  float: right;
-  display: flex;
-  align-items: center;
-  margin: 0 1em;
-}
-
-.timeline-card-buttons {
-  display: flex;
-  justify-content: end;
-}
-
-.timeline-card-button {
-  margin: 0 0.2em;
-}
\ No newline at end of file
diff --git a/FrontEnd/src/pages/timeline/TimelineCard.tsx b/FrontEnd/src/pages/timeline/TimelineCard.tsx
deleted file mode 100644
index 1e0e9b75..00000000
--- a/FrontEnd/src/pages/timeline/TimelineCard.tsx
+++ /dev/null
@@ -1,158 +0,0 @@
-import { useState } from "react";
-import { HubConnectionState } from "@microsoft/signalr";
-
-import { useUser } from "~src/services/user";
-
-import { HttpTimelineInfo } from "~src/http/timeline";
-import { getHttpBookmarkClient } from "~src/http/bookmark";
-
-import { pushAlert } from "~src/components/alert";
-import { useMobile } from "~src/components/hooks";
-import { Dialog, DialogProvider, useDialog } from "~src/components/dialog";
-import UserAvatar from "~src/components/user/UserAvatar";
-import PopupMenu from "~src/components/menu/PopupMenu";
-import FullPageDialog from "~src/components/dialog/FullPageDialog";
-import Card from "~src/components/Card";
-import TimelineDeleteDialog from "./TimelineDeleteDialog";
-import ConnectionStatusBadge from "./ConnectionStatusBadge";
-import CollapseButton from "./CollapseButton";
-import TimelineMember from "./TimelineMember";
-import TimelinePropertyChangeDialog from "./TimelinePropertyChangeDialog";
-import IconButton from "~src/components/button/IconButton";
-
-import "./TimelineCard.css";
-
-interface TimelinePageCardProps {
-  timeline: HttpTimelineInfo;
-  connectionStatus: HubConnectionState;
-  onReload: () => void;
-}
-
-export default function TimelineCard(props: TimelinePageCardProps) {
-  const { timeline, connectionStatus, onReload } = props;
-
-  const user = useUser();
-
-  const [collapse, setCollapse] = useState(true);
-  const toggleCollapse = (): void => {
-    setCollapse((o) => !o);
-  };
-
-  const isMobile = useMobile();
-
-  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>
-      <h3 className="timeline-card-title">
-        {timeline.title}
-        <small className="timeline-card-title-name">{timeline.nameV2}</small>
-      </h3>
-      <div className="timeline-card-user">
-        <UserAvatar
-          username={timeline.owner.username}
-          className="timeline-card-user-avatar"
-        />
-        <span className="timeline-card-user-nickname">
-          {timeline.owner.nickname}
-        </span>
-        <small className="timeline-card-user-username">
-          @{timeline.owner.username}
-        </small>
-      </div>
-      <p className="timeline-card-description">{timeline.description}</p>
-      <div className="timeline-card-buttons">
-        {user && (
-          <IconButton
-            icon={timeline.isBookmark ? "bookmark-fill" : "bookmark"}
-            color="primary"
-            className="timeline-card-button"
-            onClick={() => {
-              getHttpBookmarkClient()
-                [timeline.isBookmark ? "delete" : "post"](
-                  user.username,
-                  timeline.owner.username,
-                  timeline.nameV2,
-                )
-                .then(onReload, () => {
-                  pushAlert({
-                    message: timeline.isBookmark
-                      ? "timeline.removeBookmarkFail"
-                      : "timeline.addBookmarkFail",
-                    color: "danger",
-                  });
-                });
-            }}
-          />
-        )}
-        <IconButton
-          icon="people"
-          color="primary"
-          className="timeline-card-button"
-          onClick={createDialogSwitch("member")}
-        />
-        {timeline.manageable && (
-          <PopupMenu
-            items={[
-              {
-                type: "button",
-                text: "timeline.manageItem.property",
-                onClick: createDialogSwitch("property"),
-              },
-              { type: "divider" },
-              {
-                type: "button",
-                onClick: createDialogSwitch("delete"),
-                color: "danger",
-                text: "timeline.manageItem.delete",
-              },
-            ]}
-            containerClassName="d-inline"
-          >
-            <IconButton
-              color="primary"
-              className="timeline-card-button"
-              icon="three-dots-vertical"
-            />
-          </PopupMenu>
-        )}
-      </div>
-    </div>
-  );
-
-  return (
-    <Card
-      color="secondary"
-      className={`timeline-card timeline-card-${
-        collapse ? "collapse" : "expand"
-      }`}
-    >
-      <div className="timeline-card-top-right-area">
-        <ConnectionStatusBadge status={connectionStatus} />
-        <CollapseButton collapse={collapse} onClick={toggleCollapse} />
-      </div>
-      {isMobile ? (
-        <FullPageDialog
-          onBack={toggleCollapse}
-          show={!collapse}
-          contentContainerClassName="p-2"
-        >
-          {content}
-        </FullPageDialog>
-      ) : (
-        <div style={{ display: collapse ? "none" : "block" }}>{content}</div>
-      )}
-      <DialogProvider controller={controller} />
-    </Card>
-  );
-}
diff --git a/FrontEnd/src/pages/timeline/TimelineInfoCard.css b/FrontEnd/src/pages/timeline/TimelineInfoCard.css
new file mode 100644
index 00000000..29e59b62
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelineInfoCard.css
@@ -0,0 +1,63 @@
+.timeline-card {
+  position: fixed;
+  z-index: 1029;
+  top: 56px;
+  right: 0;
+  margin: 0.5em;
+  padding: 0.5em;
+  box-shadow: var(--timeline-card-shadow);
+}
+
+@media (min-width: 576px) {
+  .timeline-card-expand {
+    min-width: 400px;
+  }
+}
+
+.timeline-card-title {
+  display: inline-block;
+  vertical-align: middle;
+  color: var(--cru-text-major-color);
+  margin: 0.5em 1em;
+}
+
+.timeline-card-title-name {
+  margin-inline-start: 1em;
+  color: var(--cru-text-minor-color);
+}
+
+.timeline-card-user {
+  display: flex;
+  align-items: center;
+  margin: 0 1em 0.5em;
+}
+
+.timeline-card-user-avatar {
+  width: 2em;
+  height: 2em;
+  border-radius: 50%;
+}
+
+.timeline-card-user-nickname {
+  margin-inline: 0.6em;
+}
+
+.timeline-card-description {
+  margin: 0 1em 0.5em;
+}
+
+.timeline-card-top-right-area {
+  float: right;
+  display: flex;
+  align-items: center;
+  margin: 0 1em;
+}
+
+.timeline-card-buttons {
+  display: flex;
+  justify-content: end;
+}
+
+.timeline-card-button {
+  margin: 0 0.2em;
+}
\ No newline at end of file
diff --git a/FrontEnd/src/pages/timeline/TimelineInfoCard.tsx b/FrontEnd/src/pages/timeline/TimelineInfoCard.tsx
new file mode 100644
index 00000000..b1310be9
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelineInfoCard.tsx
@@ -0,0 +1,199 @@
+import { useState } from "react";
+import { HubConnectionState } from "@microsoft/signalr";
+
+import { useUser } from "~src/services/user";
+
+import { HttpTimelineInfo } from "~src/http/timeline";
+import { getHttpBookmarkClient } from "~src/http/bookmark";
+
+import { pushAlert } from "~src/components/alert";
+import { useMobile } from "~src/components/hooks";
+import { IconButton } from "~src/components/button";
+import {
+  Dialog,
+  FullPageDialog,
+  DialogProvider,
+  useDialog,
+} from "~src/components/dialog";
+import UserAvatar from "~src/components/user/UserAvatar";
+import PopupMenu from "~src/components/menu/PopupMenu";
+import Card from "~src/components/Card";
+
+import TimelineDeleteDialog from "./TimelineDeleteDialog";
+import ConnectionStatusBadge from "./ConnectionStatusBadge";
+import TimelineMember from "./TimelineMember";
+import TimelinePropertyChangeDialog from "./TimelinePropertyChangeDialog";
+
+import "./TimelineInfoCard.css";
+
+function CollapseButton({
+  collapse,
+  onClick,
+  className,
+}: {
+  collapse: boolean;
+  onClick: () => void;
+  className?: string;
+}) {
+  return (
+    <IconButton
+      color="primary"
+      icon={collapse ? "info-circle" : "x-circle"}
+      onClick={onClick}
+      className={className}
+    />
+  );
+}
+
+interface TimelineInfoCardProps {
+  timeline: HttpTimelineInfo;
+  connectionStatus: HubConnectionState;
+  onReload: () => void;
+}
+
+function TimelineInfoContent({
+  timeline,
+  onReload,
+}: Omit<TimelineInfoCardProps, "connectionStatus">) {
+  const user = useUser();
+
+  const { controller, createDialogSwitch } = useDialog({
+    member: (
+      <Dialog>
+        <TimelineMember timeline={timeline} onChange={onReload} />
+      </Dialog>
+    ),
+    property: (
+      <TimelinePropertyChangeDialog timeline={timeline} onChange={onReload} />
+    ),
+    delete: <TimelineDeleteDialog timeline={timeline} />,
+  });
+
+  return (
+    <div>
+      <h3 className="timeline-card-title">
+        {timeline.title}
+        <small className="timeline-card-title-name">{timeline.nameV2}</small>
+      </h3>
+      <div className="timeline-card-user">
+        <UserAvatar
+          username={timeline.owner.username}
+          className="timeline-card-user-avatar"
+        />
+        <span className="timeline-card-user-nickname">
+          {timeline.owner.nickname}
+        </span>
+        <small className="timeline-card-user-username">
+          @{timeline.owner.username}
+        </small>
+      </div>
+      <p className="timeline-card-description">{timeline.description}</p>
+      <div className="timeline-card-buttons">
+        {user && (
+          <IconButton
+            icon={timeline.isBookmark ? "bookmark-fill" : "bookmark"}
+            color="primary"
+            className="timeline-card-button"
+            onClick={() => {
+              getHttpBookmarkClient()
+                [timeline.isBookmark ? "delete" : "post"](
+                  user.username,
+                  timeline.owner.username,
+                  timeline.nameV2,
+                )
+                .then(onReload, () => {
+                  pushAlert({
+                    message: timeline.isBookmark
+                      ? "timeline.removeBookmarkFail"
+                      : "timeline.addBookmarkFail",
+                    color: "danger",
+                  });
+                });
+            }}
+          />
+        )}
+        <IconButton
+          icon="people"
+          color="primary"
+          className="timeline-card-button"
+          onClick={createDialogSwitch("member")}
+        />
+        {timeline.manageable && (
+          <PopupMenu
+            items={[
+              {
+                type: "button",
+                text: "timeline.manageItem.property",
+                onClick: createDialogSwitch("property"),
+              },
+              { type: "divider" },
+              {
+                type: "button",
+                onClick: createDialogSwitch("delete"),
+                color: "danger",
+                text: "timeline.manageItem.delete",
+              },
+            ]}
+            containerClassName="d-inline"
+          >
+            <IconButton
+              color="primary"
+              className="timeline-card-button"
+              icon="three-dots-vertical"
+            />
+          </PopupMenu>
+        )}
+      </div>
+      <DialogProvider controller={controller} />
+    </div>
+  );
+}
+
+export default function TimelineInfoCard(props: TimelineInfoCardProps) {
+  const { timeline, connectionStatus, onReload } = props;
+
+  const [collapse, setCollapse] = useState(true);
+
+  const isMobile = useMobile((mobile) => {
+    if (!mobile) {
+      switchDialog(null);
+    } else {
+      setCollapse(true);
+    }
+  });
+
+  const { controller, switchDialog } = useDialog({
+    "full-page": (
+      <FullPageDialog>
+        <TimelineInfoContent timeline={timeline} onReload={onReload} />
+      </FullPageDialog>
+    ),
+  });
+
+  return (
+    <Card
+      color="secondary"
+      className={`timeline-card timeline-card-${
+        collapse ? "collapse" : "expand"
+      }`}
+    >
+      <div className="timeline-card-top-right-area">
+        <ConnectionStatusBadge status={connectionStatus} />
+        <CollapseButton
+          collapse={collapse}
+          onClick={() => {
+            const open = collapse;
+            setCollapse(!open);
+            if (isMobile && open) {
+              switchDialog("full-page");
+            }
+          }}
+        />
+      </div>
+      {!collapse && !isMobile && (
+        <TimelineInfoContent timeline={timeline} onReload={onReload} />
+      )}
+      <DialogProvider controller={controller} />
+    </Card>
+  );
+}
-- 
cgit v1.2.3

