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

(limited to 'FrontEnd/src/components/input/InputGroup.tsx')

diff --git a/FrontEnd/src/components/input/InputGroup.tsx b/FrontEnd/src/components/input/InputGroup.tsx
new file mode 100644
index 00000000..4f487344
--- /dev/null
+++ b/FrontEnd/src/components/input/InputGroup.tsx
@@ -0,0 +1,463 @@
+/**
+ * Some notes for InputGroup:
+ * This is one of the most complicated components in this project.
+ * Probably because the feature is complex and involved user inputs.
+ *
+ * I hope it contains following features:
+ * - Input features
+ *   - Supports a wide range of input types.
+ *   - Validator to validate user inputs.
+ *   - Can set initial values.
+ *   - Dirty, aka, has user touched this input.
+ * - Developer friendly
+ *   - Easy to use APIs.
+ *   - Type check as much as possible.
+ * - UI
+ *   - Configurable appearance.
+ *   - Can display helper and error messages.
+ * - Easy to extend, like new input types.
+ *
+ * So here is some design decisions:
+ * Inputs are identified by its _key_.
+ * `InputGroup` component takes care of only UI and no logic.
+ * `useInputs` hook takes care of logic and generate props for `InputGroup`.
+ */
+
+import { useState, Ref, useId } from "react";
+import classNames from "classnames";
+
+import { useC, Text, ThemeColor } from "../common";
+
+import "./InputGroup.css";
+
+export interface InputBase {
+  key: string;
+  label: Text;
+  helper?: Text;
+  disabled?: boolean;
+  error?: Text;
+}
+
+export interface TextInput extends InputBase {
+  type: "text";
+  value: string;
+  password?: boolean;
+}
+
+export interface BoolInput extends InputBase {
+  type: "bool";
+  value: boolean;
+}
+
+export interface SelectInputOption {
+  value: string;
+  label: Text;
+  icon?: string;
+}
+
+export interface SelectInput extends InputBase {
+  type: "select";
+  value: string;
+  options: SelectInputOption[];
+}
+
+export type Input = TextInput | BoolInput | SelectInput;
+
+export type InputValue = Input["value"];
+
+export type InputValueDict = Record<string, InputValue>;
+export type InputErrorDict = Record<string, Text>;
+export type InputDisabledDict = Record<string, boolean>;
+export type InputDirtyDict = Record<string, boolean>;
+// use never so you don't have to cast everywhere
+export type InputConfirmValueDict = Record<string, never>;
+
+export type GeneralInputErrorDict =
+  | {
+      [key: string]: Text | null | undefined;
+    }
+  | null
+  | undefined;
+
+type MakeInputInfo<I extends Input> = Omit<I, "value" | "error" | "disabled">;
+
+export type InputInfo = {
+  [I in Input as I["type"]]: MakeInputInfo<I>;
+}[Input["type"]];
+
+export type Validator = (
+  values: InputValueDict,
+  inputs: InputInfo[],
+) => GeneralInputErrorDict;
+
+export type InputScheme = {
+  inputs: InputInfo[];
+  validator?: Validator;
+};
+
+export type InputData = {
+  values: InputValueDict;
+  errors: InputErrorDict;
+  disabled: InputDisabledDict;
+  dirties: InputDirtyDict;
+};
+
+export type State = {
+  scheme: InputScheme;
+  data: InputData;
+};
+
+export type DataInitialization = {
+  values?: InputValueDict;
+  errors?: GeneralInputErrorDict;
+  disabled?: InputDisabledDict;
+  dirties?: InputDirtyDict;
+};
+
+export type Initialization = {
+  scheme: InputScheme;
+  dataInit?: DataInitialization;
+};
+
+export type GeneralInitialization = Initialization | InputScheme | InputInfo[];
+
+export type Initializer = GeneralInitialization | (() => GeneralInitialization);
+
+export interface InputGroupProps {
+  color?: ThemeColor;
+  containerClassName?: string;
+  containerRef?: Ref<HTMLDivElement>;
+
+  inputs: Input[];
+  onChange: (index: number, value: Input["value"]) => void;
+}
+
+function cleanObject<V>(o: Record<string, V>): Record<string, NonNullable<V>> {
+  const result = { ...o };
+  for (const key of Object.keys(result)) {
+    if (result[key] == null) {
+      delete result[key];
+    }
+  }
+  return result as never;
+}
+
+export type ConfirmResult =
+  | {
+      type: "ok";
+      values: InputConfirmValueDict;
+    }
+  | {
+      type: "error";
+      errors: InputErrorDict;
+    };
+
+function validate(
+  validator: Validator | null | undefined,
+  values: InputValueDict,
+  inputs: InputInfo[],
+): InputErrorDict {
+  return cleanObject(validator?.(values, inputs) ?? {});
+}
+
+export function useInputs(options: { init: Initializer }): {
+  inputGroupProps: InputGroupProps;
+  hasError: boolean;
+  hasErrorAndDirty: boolean;
+  confirm: () => ConfirmResult;
+  setAllDisabled: (disabled: boolean) => void;
+} {
+  function initializeValue(
+    input: InputInfo,
+    value?: InputValue | null,
+  ): InputValue {
+    if (input.type === "text") {
+      return value ?? "";
+    } else if (input.type === "bool") {
+      return value ?? false;
+    } else if (input.type === "select") {
+      return value ?? input.options[0].value;
+    }
+    throw new Error("Unknown input type");
+  }
+
+  function initialize(generalInitialization: GeneralInitialization): State {
+    const initialization: Initialization = Array.isArray(generalInitialization)
+      ? { scheme: { inputs: generalInitialization } }
+      : "scheme" in generalInitialization
+      ? generalInitialization
+      : { scheme: generalInitialization };
+
+    const { scheme, dataInit } = initialization;
+    const { inputs, validator } = scheme;
+    const keys = inputs.map((input) => input.key);
+
+    if (process.env.NODE_ENV === "development") {
+      const checkKeys = (dict: Record<string, unknown> | undefined) => {
+        if (dict != null) {
+          for (const key of Object.keys(dict)) {
+            if (!keys.includes(key)) {
+              console.warn("");
+            }
+          }
+        }
+      };
+
+      checkKeys(dataInit?.values);
+      checkKeys(dataInit?.errors ?? {});
+      checkKeys(dataInit?.disabled);
+      checkKeys(dataInit?.dirties);
+    }
+
+    function clean<V>(
+      dict: Record<string, V> | null | undefined,
+    ): Record<string, NonNullable<V>> {
+      return dict != null ? cleanObject(dict) : {};
+    }
+
+    const values: InputValueDict = {};
+    const disabled: InputDisabledDict = clean(dataInit?.disabled);
+    const dirties: InputDirtyDict = clean(dataInit?.dirties);
+    const isErrorSet = dataInit?.errors != null;
+    let errors: InputErrorDict = clean(dataInit?.errors);
+
+    for (let i = 0; i < inputs.length; i++) {
+      const input = inputs[i];
+      const { key } = input;
+
+      values[key] = initializeValue(input, dataInit?.values?.[key]);
+    }
+
+    if (isErrorSet) {
+      if (process.env.NODE_ENV === "development") {
+        console.log(
+          "You explicitly set errors (not undefined) in initializer, so validator won't run.",
+        );
+      }
+    } else {
+      errors = validate(validator, values, inputs);
+    }
+
+    return {
+      scheme,
+      data: {
+        values,
+        errors,
+        disabled,
+        dirties,
+      },
+    };
+  }
+
+  const { init } = options;
+  const initializer = typeof init === "function" ? init : () => init;
+
+  const [state, setState] = useState<State>(() => initialize(initializer()));
+
+  const { scheme, data } = state;
+  const { validator } = scheme;
+
+  function createAllBooleanDict(value: boolean): Record<string, boolean> {
+    const result: InputDirtyDict = {};
+    for (const key of scheme.inputs.map((input) => input.key)) {
+      result[key] = value;
+    }
+    return result;
+  }
+
+  const createAllDirties = () => createAllBooleanDict(true);
+
+  const componentInputs: Input[] = [];
+
+  for (let i = 0; i < scheme.inputs.length; i++) {
+    const input = scheme.inputs[i];
+    const value = data.values[input.key];
+    const error = data.errors[input.key];
+    const disabled = data.disabled[input.key] ?? false;
+    const dirty = data.dirties[input.key] ?? false;
+    const componentInput: Input = {
+      ...input,
+      value: value as never,
+      disabled,
+      error: dirty ? error : undefined,
+    };
+    componentInputs.push(componentInput);
+  }
+
+  const hasError = Object.keys(data.errors).length > 0;
+  const hasDirty = Object.keys(data.dirties).some((key) => data.dirties[key]);
+
+  return {
+    inputGroupProps: {
+      inputs: componentInputs,
+      onChange: (index, value) => {
+        const input = scheme.inputs[index];
+        const { key } = input;
+        const newValues = { ...data.values, [key]: value };
+        const newDirties = { ...data.dirties, [key]: true };
+        const newErrors = validate(validator, newValues, scheme.inputs);
+        setState({
+          scheme,
+          data: {
+            ...data,
+            values: newValues,
+            errors: newErrors,
+            dirties: newDirties,
+          },
+        });
+      },
+    },
+    hasError,
+    hasErrorAndDirty: hasError && hasDirty,
+    confirm() {
+      const newDirties = createAllDirties();
+      const newErrors = validate(validator, data.values, scheme.inputs);
+
+      setState({
+        scheme,
+        data: {
+          ...data,
+          dirties: newDirties,
+          errors: newErrors,
+        },
+      });
+
+      if (Object.keys(newErrors).length !== 0) {
+        return {
+          type: "error",
+          errors: newErrors,
+        };
+      } else {
+        return {
+          type: "ok",
+          values: data.values as InputConfirmValueDict,
+        };
+      }
+    },
+    setAllDisabled(disabled: boolean) {
+      setState({
+        scheme,
+        data: {
+          ...data,
+          disabled: createAllBooleanDict(disabled),
+        },
+      });
+    },
+  };
+}
+
+export function InputGroup({
+  color,
+  inputs,
+  onChange,
+  containerRef,
+  containerClassName,
+}: InputGroupProps) {
+  const c = useC();
+
+  const id = useId();
+
+  return (
+    <div
+      ref={containerRef}
+      className={classNames(
+        "cru-input-group",
+        `cru-clickable-${color ?? "primary"}`,
+        containerClassName,
+      )}
+    >
+      {inputs.map((item, index) => {
+        const { key, type, value, label, error, helper, disabled } = item;
+
+        const getContainerClassName = (
+          ...additionalClassNames: classNames.ArgumentArray
+        ) =>
+          classNames(
+            `cru-input-container cru-input-type-${type}`,
+            error && "error",
+            ...additionalClassNames,
+          );
+
+        const changeValue = (value: InputValue) => {
+          onChange(index, value);
+        };
+
+        const inputId = `${id}-${key}`;
+
+        if (type === "text") {
+          const { password } = item;
+          return (
+            <div
+              key={key}
+              className={getContainerClassName(password && "password")}
+            >
+              {label && (
+                <label className="cru-input-label" htmlFor={inputId}>
+                  {c(label)}
+                </label>
+              )}
+              <input
+                id={inputId}
+                type={password ? "password" : "text"}
+                value={value}
+                onChange={(event) => {
+                  const v = event.target.value;
+                  changeValue(v);
+                }}
+                disabled={disabled}
+              />
+              {error && <div className="cru-input-error">{c(error)}</div>}
+              {helper && <div className="cru-input-helper">{c(helper)}</div>}
+            </div>
+          );
+        } else if (type === "bool") {
+          return (
+            <div key={key} className={getContainerClassName()}>
+              <input
+                id={inputId}
+                type="checkbox"
+                checked={value}
+                onChange={(event) => {
+                  const v = event.currentTarget.checked;
+                  changeValue(v);
+                }}
+                disabled={disabled}
+              />
+              <label className="cru-input-label-inline" htmlFor={inputId}>
+                {c(label)}
+              </label>
+              {error && <div className="cru-input-error">{c(error)}</div>}
+              {helper && <div className="cru-input-helper">{c(helper)}</div>}
+            </div>
+          );
+        } else if (type === "select") {
+          return (
+            <div key={key} className={getContainerClassName()}>
+              <label className="cru-input-label" htmlFor={inputId}>
+                {c(label)}
+              </label>
+              <select
+                id={inputId}
+                value={value}
+                onChange={(event) => {
+                  const e = event.target.value;
+                  changeValue(e);
+                }}
+                disabled={disabled}
+              >
+                {item.options.map((option) => {
+                  return (
+                    <option value={option.value} key={option.value}>
+                      {option.icon}
+                      {c(option.label)}
+                    </option>
+                  );
+                })}
+              </select>
+            </div>
+          );
+        }
+      })}
+    </div>
+  );
+}
-- 
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/input/InputGroup.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

