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

(limited to 'FrontEnd/src/components/LoadFailReload.tsx')

diff --git a/FrontEnd/src/components/LoadFailReload.tsx b/FrontEnd/src/components/LoadFailReload.tsx
new file mode 100644
index 00000000..81ba1f67
--- /dev/null
+++ b/FrontEnd/src/components/LoadFailReload.tsx
@@ -0,0 +1,37 @@
+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;
-- 
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/LoadFailReload.tsx')

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


From b66a57071316434356e77e294ec22181e4db54d5 Mon Sep 17 00:00:00 2001
From: crupest <crupest@outlook.com>
Date: Mon, 28 Aug 2023 01:41:41 +0800
Subject: ...

---
 FrontEnd/src/components/BlobImage.tsx             |  15 +-
 FrontEnd/src/components/ImageCropper.css          |  15 +-
 FrontEnd/src/components/ImageCropper.tsx          | 564 +++++++++++++---------
 FrontEnd/src/components/LoadFailReload.tsx        |  37 ++
 FrontEnd/src/pages/setting/ChangeAvatarDialog.css |   1 +
 FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx |  34 +-
 FrontEnd/src/pages/timeline/TimelineCard.tsx      |   2 +-
 7 files changed, 394 insertions(+), 274 deletions(-)
 create mode 100644 FrontEnd/src/components/LoadFailReload.tsx

(limited to 'FrontEnd/src/components/LoadFailReload.tsx')

diff --git a/FrontEnd/src/components/BlobImage.tsx b/FrontEnd/src/components/BlobImage.tsx
index 259c2210..cccf2f74 100644
--- a/FrontEnd/src/components/BlobImage.tsx
+++ b/FrontEnd/src/components/BlobImage.tsx
@@ -1,12 +1,13 @@
-import { ComponentPropsWithoutRef, useState, useEffect } from "react";
+import { ComponentPropsWithoutRef, useState, useEffect, useMemo } from "react";
 
 type BlobImageProps = Omit<ComponentPropsWithoutRef<"img">, "src"> & {
   imgRef?: React.Ref<HTMLImageElement>;
   src?: Blob | string | null;
+  keyBySrc?: boolean;
 };
 
 export default function BlobImage(props: BlobImageProps) {
-  const { imgRef, src, ...otherProps } = props;
+  const { imgRef, src, keyBySrc, ...otherProps } = props;
 
   const [url, setUrl] = useState<string | null | undefined>(undefined);
 
@@ -22,5 +23,13 @@ export default function BlobImage(props: BlobImageProps) {
     }
   }, [src]);
 
-  return <img ref={imgRef} {...otherProps} src={url ?? undefined} />;
+  const key = useMemo(() => {
+    if (keyBySrc) {
+      return url == null ? undefined : btoa(url);
+    } else {
+      return undefined;
+    }
+  }, [url, keyBySrc]);
+
+  return <img key={key} ref={imgRef} {...otherProps} src={url ?? undefined} />;
 }
diff --git a/FrontEnd/src/components/ImageCropper.css b/FrontEnd/src/components/ImageCropper.css
index 2c4d0a8c..9631cf1d 100644
--- a/FrontEnd/src/components/ImageCropper.css
+++ b/FrontEnd/src/components/ImageCropper.css
@@ -1,18 +1,17 @@
-.image-cropper-container {
+.cru-image-cropper-container {
   position: relative;
   box-sizing: border-box;
   user-select: none;
 }
 
-.image-cropper-container img {
-  position: absolute;
+.cru-image-cropper-container img {
   left: 0;
   top: 0;
   width: 100%;
   height: 100%;
 }
 
-.image-cropper-mask-container {
+.cru-image-cropper-mask-container {
   position: absolute;
   left: 0;
   top: 0;
@@ -21,18 +20,16 @@
   overflow: hidden;
 }
 
-.image-cropper-mask {
+.cru-image-cropper-mask {
   position: absolute;
   box-shadow: 0 0 0 10000px rgba(255, 255, 255, 0.8);
   touch-action: none;
 }
 
-.image-cropper-handler {
+.cru-image-cropper-handler {
   position: absolute;
-  width: 26px;
-  height: 26px;
   border: black solid 2px;
   border-radius: 50%;
   background: white;
   touch-action: none;
-}
+}
\ No newline at end of file
diff --git a/FrontEnd/src/components/ImageCropper.tsx b/FrontEnd/src/components/ImageCropper.tsx
index f23994e2..1f530004 100644
--- a/FrontEnd/src/components/ImageCropper.tsx
+++ b/FrontEnd/src/components/ImageCropper.tsx
@@ -1,312 +1,396 @@
-import * as React from "react";
+import {
+  useState,
+  useRef,
+  SyntheticEvent,
+  PointerEvent,
+  useMemo,
+  MutableRefObject,
+} from "react";
 import classnames from "classnames";
 
-import { UiLogicError } from "~src/common";
+import { UiLogicError } from "./common";
+import BlobImage from "./BlobImage";
 
 import "./ImageCropper.css";
-import BlobImage from "./BlobImage";
 
+// All in natural size of image.
 export interface Clip {
   left: number;
   top: number;
   width: number;
+  height: number;
 }
 
-interface NormailizedClip extends Clip {
-  height: number;
+export function applyClipToImage(
+  image: HTMLImageElement,
+  clip: Clip,
+  mimeType: string,
+): Promise<Blob> {
+  return new Promise((resolve, reject) => {
+    const canvas = document.createElement("canvas");
+    canvas.width = clip.width;
+    canvas.height = clip.height;
+    const context = canvas.getContext("2d");
+
+    if (context == null) throw new Error("Failed to create context.");
+
+    context.drawImage(
+      image,
+      clip.left,
+      clip.top,
+      clip.width,
+      clip.height,
+      0,
+      0,
+      clip.width,
+      clip.height,
+    );
+
+    canvas.toBlob((blob) => {
+      if (blob == null) {
+        reject(new Error("canvas.toBlob returns null"));
+      } else {
+        resolve(blob);
+      }
+    }, mimeType);
+  });
+}
+
+interface Movement {
+  x: number;
+  y: number;
 }
 
 interface ImageInfo {
+  element: HTMLImageElement;
   width: number;
   height: number;
-  landscape: boolean;
   ratio: number;
-  maxClipWidth: number;
-  maxClipHeight: number;
+  landscape: boolean;
 }
 
-interface ImageCropperSavedState {
-  clip: NormailizedClip;
-  x: number;
-  y: number;
-  pointerId: number;
+export interface CropConstraint {
+  ratio?: number;
+  //   minClipWidth?: number;
+  //   minClipHeight?: number;
+  //   maxClipWidth?: number;
+  //   maxClipHeight?: number;
 }
 
-export interface ImageCropperProps {
-  clip: Clip | null;
-  image: string | Blob;
-  onChange: (clip: Clip) => void;
-  imageElementCallback?: (element: HTMLImageElement | null) => void;
-  className?: string;
+function generateImageInfo(
+  imageElement: HTMLImageElement | null,
+): ImageInfo | null {
+  if (imageElement == null) return null;
+
+  const { naturalWidth, naturalHeight } = imageElement;
+  const imageRatio = naturalHeight / naturalWidth;
+
+  return {
+    element: imageElement,
+    width: naturalWidth,
+    height: naturalHeight,
+    ratio: imageRatio,
+    landscape: imageRatio < 1,
+  };
 }
 
-const ImageCropper = (props: ImageCropperProps): React.ReactElement => {
-  const { clip, image, onChange, imageElementCallback, className } = props;
+const emptyClip: Clip = {
+  left: 0,
+  top: 0,
+  width: 0,
+  height: 0,
+};
 
-  const [oldState, setOldState] = React.useState<ImageCropperSavedState | null>(
-    null,
-  );
-  const [imageInfo, setImageInfo] = React.useState<ImageInfo | null>(null);
+const allClip : Clip = {
+  left: 0,
+  top: 0,
+  width: Number.MAX_VALUE,
+  height: Number.MAX_VALUE,
+}
 
-  const normalizeClip = (c: Clip | null | undefined): NormailizedClip => {
-    if (c == null) {
-      return { left: 0, top: 0, width: 0, height: 0 };
+// TODO: Continue here... mode...
+function adjustClip(
+  clip: Clip,
+  mode: "move" | "resize" | "both",
+  imageSize: { width: number; height: number },
+  targetRatio?: number | null | undefined,
+): Clip {
+  class ClipGeometry {
+    constructor(
+      public left: number,
+      public top: number,
+      public width: number,
+      public height: number,
+    ) {}
+
+    get right(): number {
+      return this.left + this.width;
     }
 
-    return {
-      left: c.left || 0,
-      top: c.top || 0,
-      width: c.width || 0,
-      height: imageInfo != null ? (c.width || 0) / imageInfo.ratio : 0,
-    };
-  };
+    set right(value: number) {
+      this.width = this.left + value;
+    }
 
-  const c = normalizeClip(clip);
+    get bottom(): number {
+      return this.top + this.height;
+    }
 
-  const imgElementRef = React.useRef<HTMLImageElement | null>(null);
+    set bottom(value: number) {
+      this.height = this.top + value;
+    }
 
-  const onImageRef = React.useCallback(
-    (e: HTMLImageElement | null) => {
-      imgElementRef.current = e;
-      if (imageElementCallback != null && e == null) {
-        imageElementCallback(null);
-      }
-    },
-    [imageElementCallback],
-  );
+    get ratio(): number {
+      return this.height / this.width;
+    }
 
-  const onImageLoad = React.useCallback(
-    (e: React.SyntheticEvent<HTMLImageElement>) => {
-      const img = e.currentTarget;
-      const landscape = img.naturalWidth >= img.naturalHeight;
-
-      const info = {
-        width: img.naturalWidth,
-        height: img.naturalHeight,
-        landscape,
-        ratio: img.naturalHeight / img.naturalWidth,
-        maxClipWidth: landscape ? img.naturalHeight / img.naturalWidth : 1,
-        maxClipHeight: landscape ? 1 : img.naturalWidth / img.naturalHeight,
+    toClip(): Clip {
+      return {
+        left: this.left,
+        top: this.top,
+        width: this.width,
+        height: this.height,
       };
-      setImageInfo(info);
-      onChange({ left: 0, top: 0, width: info.maxClipWidth });
-      if (imageElementCallback != null) {
-        imageElementCallback(img);
-      }
-    },
-    [onChange, imageElementCallback],
-  );
+    }
+  }
 
-  const onPointerDown = React.useCallback(
-    (e: React.PointerEvent) => {
-      if (oldState != null) return;
-      e.currentTarget.setPointerCapture(e.pointerId);
-      setOldState({
-        x: e.clientX,
-        y: e.clientY,
-        clip: c,
-        pointerId: e.pointerId,
-      });
-    },
-    [oldState, c],
+  const clipGeometry = new ClipGeometry(
+    clip.left,
+    clip.top,
+    clip.width,
+    clip.height,
   );
 
-  const onPointerUp = React.useCallback(
-    (e: React.PointerEvent) => {
-      if (oldState == null || oldState.pointerId !== e.pointerId) return;
-      e.currentTarget.releasePointerCapture(e.pointerId);
-      setOldState(null);
-    },
-    [oldState],
-  );
-
-  const onPointerMove = React.useCallback(
-    (e: React.PointerEvent) => {
-      if (oldState == null) return;
-
-      const oldClip = oldState.clip;
-
-      const movement = { x: e.clientX - oldState.x, y: e.clientY - oldState.y };
-
-      const { current: imgElement } = imgElementRef;
-
-      if (imgElement == null) throw new UiLogicError("Image element is null.");
+  // Make clip in image.
+  clipGeometry.left = Math.max(clipGeometry.left, 0);
+  clipGeometry.top = Math.max(clipGeometry.top, 0);
+  clipGeometry.right = Math.min(clipGeometry.right, imageSize.width);
+  clipGeometry.bottom = Math.min(clipGeometry.bottom, imageSize.height);
+
+  // Make image "positive"
+  if (clipGeometry.right < clipGeometry.left) {
+    clipGeometry.right = clipGeometry.left;
+  }
+  if (clipGeometry.bottom < clipGeometry.top) {
+    clipGeometry.bottom = clipGeometry.top;
+  }
+
+  // Now correct ratio
+  const currentRatio = clipGeometry.ratio;
+  if (targetRatio != null && targetRatio > 0 && currentRatio !== targetRatio) {
+    if (currentRatio < targetRatio) {
+      // too wide
+      clipGeometry.width = clipGeometry.height / targetRatio;
+    } else {
+      clipGeometry.height = clipGeometry.width * targetRatio;
+    }
+  }
 
-      const moveRatio = {
-        x: movement.x / imgElement.width,
-        y: movement.y / imgElement.height,
-      };
+  return clipGeometry.toClip();
+}
 
-      const newRatio = {
-        x: oldClip.left + moveRatio.x,
-        y: oldClip.top + moveRatio.y,
-      };
-      if (newRatio.x < 0) {
-        newRatio.x = 0;
-      } else if (newRatio.x > 1 - oldClip.width) {
-        newRatio.x = 1 - oldClip.width;
-      }
-      if (newRatio.y < 0) {
-        newRatio.y = 0;
-      } else if (newRatio.y > 1 - oldClip.height) {
-        newRatio.y = 1 - oldClip.height;
-      }
+interface ImageCropperProps {
+  clip: Clip;
+  image: Blob | string | null;
+  imageElementRef: MutableRefObject<HTMLImageElement | null>;
+  onImageLoad: (event: SyntheticEvent<HTMLImageElement>) => void;
+  onMove: (movement: Movement) => void;
+  onResize: (movement: Movement) => void;
+  containerClassName?: string;
+}
 
-      onChange({ left: newRatio.x, top: newRatio.y, width: oldClip.width });
+export function useImageCrop(
+  file: File | null,
+  options?: {
+    constraint?: CropConstraint;
+  },
+): {
+  clip: Clip;
+  setClip: (clip: Clip) => void;
+  canCrop: boolean;
+  crop: () => Promise<Blob>;
+  imageCropperProps: ImageCropperProps;
+} {
+  const targetRatio = options?.constraint?.ratio;
+
+  const imageElementRef = useRef<HTMLImageElement | null>(null);
+  const [image, setImage] = useState<ImageInfo | null>(null);
+  const [clip, setClip] = useState<Clip>(emptyClip  );
+
+  if (imageElementRef.current == null && image != null) {
+    setImage(null);
+    setClip(emptyClip);
+  }
+
+  const canCrop = file != null && image != null;
+
+  const adjustedClip = useMemo(() => {
+    return image == null ? emptyClip : adjustClip(clip, image, targetRatio);
+  }, [clip, image, targetRatio]);
+
+  return {
+    clip,
+    setClip,
+    canCrop,
+    crop() {
+      if (!canCrop) throw new UiLogicError();
+      return applyClipToImage(image.element, adjustedClip, file.type);
     },
-    [oldState, onChange],
-  );
-
-  const onHandlerPointerMove = React.useCallback(
-    (e: React.PointerEvent) => {
-      if (oldState == null) return;
+    imageCropperProps: {
+      clip: adjustedClip,
+      image: file,
+      imageElementRef: imageElementRef,
+      // TODO: Continue here...
+      onMove: ,
+      onResize: ,
+      onImageLoad: () => {
+        const image = generateImageInfo(imageElementRef.current);
+        setImage(image);
+        setClip(adjustClip(allClip, "both", image, targetRatio));
+      },
+    },
+  };
+}
 
-      const oldClip = oldState.clip;
+interface PointerState {
+  x: number;
+  y: number;
+  pointerId: number;
+}
 
-      const movement = { x: e.clientX - oldState.x, y: e.clientY - oldState.y };
+const imageCropperHandlerSize = 15;
 
-      const ratio = imageInfo == null ? 1 : imageInfo.ratio;
+export function ImageCropper(props: ImageCropperProps) {
+  function convertClipToElement(
+    clip: Clip,
+    imageElement: HTMLImageElement,
+  ): Clip {
+    const xRatio = imageElement.clientWidth / imageElement.naturalWidth;
+    const yRatio = imageElement.clientHeight / imageElement.naturalHeight;
+    return {
+      left: xRatio * clip.left,
+      top: yRatio * clip.top,
+      width: xRatio * clip.width,
+      height: yRatio * clip.height,
+    };
+  }
+
+  function convertMovementFromElement(
+    move: Movement,
+    imageElement: HTMLImageElement,
+  ): Movement {
+    const xRatio = imageElement.naturalWidth / imageElement.clientWidth;
+    const yRatio = imageElement.naturalHeight / imageElement.clientHeight;
+    return {
+      x: xRatio * move.x,
+      y: yRatio * move.y,
+    };
+  }
+
+  const {
+    clip,
+    image,
+    imageElementRef,
+    onImageLoad,
+    onMove,
+    onResize,
+    containerClassName,
+  } = props;
+
+  const pointerStateRef = useRef<PointerState | null>(null);
+
+  const clipInElement =
+    imageElementRef.current != null
+      ? convertClipToElement(clip, imageElementRef.current)
+      : emptyClip;
+
+  const actOnMovement = (
+    e: PointerEvent,
+    change: (movement: Movement) => void,
+  ) => {
+    if (
+      imageElementRef.current == null ||
+      pointerStateRef.current == null ||
+      pointerStateRef.current.pointerId != e.pointerId
+    ) {
+      return;
+    }
 
-      const { current: imgElement } = imgElementRef;
+    const { x, y } = pointerStateRef.current;
 
-      if (imgElement == null) throw new UiLogicError("Image element is null.");
+    const movement = {
+      x: e.clientX - x,
+      y: e.clientY - y,
+    };
 
-      const moveRatio = {
-        x: movement.x / imgElement.width,
-        y: movement.x / imgElement.width / ratio,
-      };
+    change(movement);
+  };
 
-      const newRatio = {
-        x: oldClip.width + moveRatio.x,
-        y: oldClip.height + moveRatio.y,
-      };
+  const onPointerDown = (e: PointerEvent) => {
+    if (imageElementRef.current == null || pointerStateRef.current != null)
+      return;
 
-      const maxRatio = {
-        x: Math.min(1 - oldClip.left, newRatio.x),
-        y: Math.min(1 - oldClip.top, newRatio.y),
-      };
+    e.currentTarget.setPointerCapture(e.pointerId);
 
-      const maxWidthRatio = Math.min(maxRatio.x, maxRatio.y * ratio);
+    pointerStateRef.current = {
+      x: e.clientX,
+      y: e.clientY,
+      pointerId: e.pointerId,
+    };
+  };
 
-      let newWidth;
-      if (newRatio.x < 0) {
-        newWidth = 0;
-      } else if (newRatio.x > maxWidthRatio) {
-        newWidth = maxWidthRatio;
-      } else {
-        newWidth = newRatio.x;
-      }
+  const onPointerUp = (e: PointerEvent) => {
+    if (
+      pointerStateRef.current == null ||
+      pointerStateRef.current.pointerId != e.pointerId
+    ) {
+      return;
+    }
 
-      onChange({ left: oldClip.left, top: oldClip.top, width: newWidth });
-    },
-    [imageInfo, oldState, onChange],
-  );
+    e.currentTarget.releasePointerCapture(e.pointerId);
+    pointerStateRef.current = null;
+  };
 
-  const toPercentage = (n: number): string => `${n}%`;
+  const onMaskPointerMove = (e: PointerEvent) => {
+    actOnMovement(e, onMove);
+  };
 
-  // fuck!!! I just can't find a better way to implement this in pure css
-  const containerStyle: React.CSSProperties = (() => {
-    if (imageInfo == null) {
-      return { width: "100%", paddingTop: "100%", height: 0 };
-    } else {
-      if (imageInfo.ratio > 1) {
-        return {
-          width: toPercentage(100 / imageInfo.ratio),
-          paddingTop: "100%",
-          height: 0,
-        };
-      } else {
-        return {
-          width: "100%",
-          paddingTop: toPercentage(100 * imageInfo.ratio),
-          height: 0,
-        };
-      }
-    }
-  })();
+  const onResizeHandlerPointerMove = (e: PointerEvent) => {
+    actOnMovement(e, onResize);
+  };
 
   return (
     <div
-      className={classnames("image-cropper-container", className)}
-      style={containerStyle}
+      className={classnames("cru-image-cropper-container", containerClassName)}
     >
-      <BlobImage
-        imgRef={onImageRef}
-        src={image}
-        onLoad={onImageLoad}
-        alt="to crop"
-      />
-      <div className="image-cropper-mask-container">
+      <BlobImage imgRef={imageElementRef} src={image} onLoad={onImageLoad} />
+      <div className="cru-image-cropper-mask-container">
         <div
-          className="image-cropper-mask"
+          className="cru-image-cropper-mask"
           style={{
-            left: toPercentage(c.left * 100),
-            top: toPercentage(c.top * 100),
-            width: toPercentage(c.width * 100),
-            height: toPercentage(c.height * 100),
+            left: clipInElement.left,
+            top: clipInElement.top,
+            width: clipInElement.width,
+            height: clipInElement.height,
           }}
-          onPointerMove={onPointerMove}
+          onPointerMove={onMaskPointerMove}
           onPointerDown={onPointerDown}
           onPointerUp={onPointerUp}
         />
       </div>
       <div
-        className="image-cropper-handler"
+        className="cru-image-cropper-handler"
         style={{
-          left: `calc(${(c.left + c.width) * 100}% - 15px)`,
-          top: `calc(${(c.top + c.height) * 100}% - 15px)`,
+          left:
+            clipInElement.left + clipInElement.width - imageCropperHandlerSize,
+          top:
+            clipInElement.top + clipInElement.height - imageCropperHandlerSize,
+          width: imageCropperHandlerSize * 2,
+          height: imageCropperHandlerSize * 2,
         }}
-        onPointerMove={onHandlerPointerMove}
+        onPointerMove={onResizeHandlerPointerMove}
         onPointerDown={onPointerDown}
         onPointerUp={onPointerUp}
       />
     </div>
   );
-};
-
-export default ImageCropper;
-
-export function applyClipToImage(
-  image: HTMLImageElement,
-  clip: Clip,
-  mimeType: string,
-): Promise<Blob> {
-  return new Promise((resolve, reject) => {
-    const naturalSize = {
-      width: image.naturalWidth,
-      height: image.naturalHeight,
-    };
-    const clipArea = {
-      x: naturalSize.width * clip.left,
-      y: naturalSize.height * clip.top,
-      length: naturalSize.width * clip.width,
-    };
-
-    const canvas = document.createElement("canvas");
-    canvas.width = clipArea.length;
-    canvas.height = clipArea.length;
-    const context = canvas.getContext("2d");
-
-    if (context == null) throw new Error("Failed to create context.");
-
-    context.drawImage(
-      image,
-      clipArea.x,
-      clipArea.y,
-      clipArea.length,
-      clipArea.length,
-      0,
-      0,
-      clipArea.length,
-      clipArea.length,
-    );
-
-    canvas.toBlob((blob) => {
-      if (blob == null) {
-        reject(new Error("canvas.toBlob returns null"));
-      } else {
-        resolve(blob);
-      }
-    }, mimeType);
-  });
 }
diff --git a/FrontEnd/src/components/LoadFailReload.tsx b/FrontEnd/src/components/LoadFailReload.tsx
new file mode 100644
index 00000000..81ba1f67
--- /dev/null
+++ b/FrontEnd/src/components/LoadFailReload.tsx
@@ -0,0 +1,37 @@
+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/pages/setting/ChangeAvatarDialog.css b/FrontEnd/src/pages/setting/ChangeAvatarDialog.css
index 2aa0bb54..c9eb8011 100644
--- a/FrontEnd/src/pages/setting/ChangeAvatarDialog.css
+++ b/FrontEnd/src/pages/setting/ChangeAvatarDialog.css
@@ -11,6 +11,7 @@
 }
 
 .change-avatar-cropper {
+  max-width: 400px;
   max-height: 400px;
 }
 
diff --git a/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx b/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx
index 011c5059..2fcfef2c 100644
--- a/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx
+++ b/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx
@@ -6,10 +6,7 @@ import { useUser } from "~src/services/user";
 
 import { getHttpUserClient } from "~src/http/user";
 
-import ImageCropper, {
-  Clip,
-  applyClipToImage,
-} from "~src/components/ImageCropper";
+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";
@@ -40,10 +37,13 @@ export default function ChangeAvatarDialog({
   const [state, setState] = useState<State>("select");
 
   const [file, setFile] = useState<File | null>(null);
-  const [clip, setClip] = useState<Clip | null>(null);
-  const [cropImgElement, setCropImgElement] = useState<HTMLImageElement | null>(
-    null,
-  );
+
+  const { canCrop, crop, imageCropperProps } = useImageCrop(file, {
+    constraint: {
+      ratio: 1,
+    },
+  });
+
   const [resultBlob, setResultBlob] = useState<Blob | null>(null);
   const [message, setMessage] = useState<Text>(
     "settings.dialogChangeAvatar.prompt.select",
@@ -65,18 +65,13 @@ export default function ChangeAvatarDialog({
   };
 
   const onCropNext = () => {
-    if (
-      cropImgElement == null ||
-      clip == null ||
-      clip.width === 0 ||
-      file == null
-    ) {
+    if (!canCrop) {
       throw new UiLogicError();
     }
 
     setState("process-crop");
 
-    void applyClipToImage(cropImgElement, clip, file.type).then((b) => {
+    void crop().then((b) => {
       setState("preview");
       setResultBlob(b);
     });
@@ -151,7 +146,7 @@ export default function ChangeAvatarDialog({
         action: "primary",
         text: "operationDialog.nextStep",
         onClick: onCropNext,
-        disabled: cropImgElement == null || clip == null || clip.width === 0,
+        disabled: !canCrop,
       },
     ],
     "process-crop": [cancelButton, createPreviousButton(onPreviewPrevious)],
@@ -214,11 +209,8 @@ export default function ChangeAvatarDialog({
             return (
               <div className="change-avatar-dialog-container">
                 <ImageCropper
-                  className="change-avatar-cropper"
-                  clip={clip}
-                  onChange={setClip}
-                  image={file}
-                  imageElementCallback={setCropImgElement}
+                  {...imageCropperProps}
+                  containerClassName="change-avatar-cropper"
                 />
                 <div className="change-avatar-dialog-prompt">
                   {c("settings.dialogChangeAvatar.prompt.crop")}
diff --git a/FrontEnd/src/pages/timeline/TimelineCard.tsx b/FrontEnd/src/pages/timeline/TimelineCard.tsx
index 82d6d350..f17a3ce9 100644
--- a/FrontEnd/src/pages/timeline/TimelineCard.tsx
+++ b/FrontEnd/src/pages/timeline/TimelineCard.tsx
@@ -7,7 +7,7 @@ import { pushAlert } from "~src/services/alert";
 import { HttpTimelineInfo } from "~src/http/timeline";
 import { getHttpBookmarkClient } from "~src/http/bookmark";
 
-import { useMobile } from "~src/components/common";
+import { useMobile } from "~src/components/hooks";
 import { Dialog, useDialog } from "~src/components/dialog";
 import UserAvatar from "~src/components/user/UserAvatar";
 import PopupMenu from "~src/components/menu/PopupMenu";
-- 
cgit v1.2.3

