From 26b861b9ce3b3966feab51b865bd3bd0630ae4c9 Mon Sep 17 00:00:00 2001
From: crupest <crupest@outlook.com>
Date: Fri, 12 Mar 2021 17:46:27 +0800
Subject: ...

---
 .../app/views/timeline-common/MarkdownPostEdit.tsx | 116 +++++++++++++++++++++
 1 file changed, 116 insertions(+)
 create mode 100644 FrontEnd/src/app/views/timeline-common/MarkdownPostEdit.tsx

(limited to 'FrontEnd/src/app/views/timeline-common/MarkdownPostEdit.tsx')

diff --git a/FrontEnd/src/app/views/timeline-common/MarkdownPostEdit.tsx b/FrontEnd/src/app/views/timeline-common/MarkdownPostEdit.tsx
new file mode 100644
index 00000000..079344e1
--- /dev/null
+++ b/FrontEnd/src/app/views/timeline-common/MarkdownPostEdit.tsx
@@ -0,0 +1,116 @@
+import React from "react";
+import { Nav, Form } from "react-bootstrap";
+import { useTranslation } from "react-i18next";
+
+import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline";
+
+import TimelinePostBuilder from "@/services/TimelinePostBuilder";
+
+export interface MarkdownPostEditProps {
+  timeline: string;
+  onPosted: (post: HttpTimelinePostInfo) => void;
+}
+
+const MarkdownPostEdit: React.FC<MarkdownPostEditProps> = ({
+  timeline: timelineName,
+  onPosted,
+}) => {
+  const { t } = useTranslation();
+
+  const [tab, setTab] = React.useState<"text" | "images" | "preview">("text");
+
+  const [process, setProcess] = React.useState<boolean>(false);
+
+  const [text, _setText] = React.useState<string>("");
+  const [images, _setImages] = React.useState<{ file: File; url: string }[]>(
+    []
+  );
+  const [previewHtml, _setPreviewHtml] = React.useState<string>("");
+
+  const _builder = React.useRef<TimelinePostBuilder | null>(null);
+
+  const getBuilder = (): TimelinePostBuilder => {
+    if (_builder.current == null) {
+      const builder = new TimelinePostBuilder(() => {
+        _setText(builder.text);
+        _setImages(builder.images);
+        _setPreviewHtml(builder.renderHtml());
+      });
+      _builder.current = builder;
+    }
+    return _builder.current;
+  };
+
+  React.useEffect(() => {
+    return () => {
+      getBuilder().dispose();
+    };
+  }, []);
+
+  const send = async (): Promise<void> => {
+    const dataList = await getBuilder().build();
+    const post = await getHttpTimelineClient().postPost(timelineName, {
+      dataList,
+    });
+    onPosted(post);
+  };
+
+  return (
+    <div>
+      <Nav variant="tabs" className="my-2">
+        <Nav.Item>
+          <Nav.Link
+            active={tab === "text"}
+            onClick={() => {
+              setTab("text");
+            }}
+          >
+            {t("edit")}
+          </Nav.Link>
+        </Nav.Item>
+        <Nav.Item>
+          <Nav.Link
+            active={tab === "images"}
+            onClick={() => {
+              setTab("images");
+            }}
+          >
+            {t("image")}
+          </Nav.Link>
+        </Nav.Item>
+        <Nav.Item>
+          <Nav.Link
+            active={tab === "preview"}
+            onClick={() => {
+              setTab("preview");
+            }}
+          >
+            {t("preview")}
+          </Nav.Link>
+        </Nav.Item>
+      </Nav>
+      <div>
+        {(() => {
+          if (tab === "text") {
+            return (
+              <Form.Control
+                as="textarea"
+                value={text}
+                disabled={process}
+                onChange={(event) => {
+                  getBuilder().setMarkdownText(event.currentTarget.value);
+                }}
+              />
+            );
+          } else if (tab === "images") {
+            return <div></div>;
+          } else {
+            return <div dangerouslySetInnerHTML={{ __html: previewHtml }} />;
+          }
+        })()}
+      </div>
+    </div>
+  );
+};
+
+export default MarkdownPostEdit;
-- 
cgit v1.2.3


From e83da106a259f4a8ab11324eceef2c15a9a08bf0 Mon Sep 17 00:00:00 2001
From: crupest <crupest@outlook.com>
Date: Thu, 18 Mar 2021 21:01:07 +0800
Subject: ...

---
 FrontEnd/src/app/index.sass                        |   5 +
 FrontEnd/src/app/services/TimelinePostBuilder.ts   |  32 ++--
 FrontEnd/src/app/views/common/TabPages.tsx         |  74 +++++++++
 FrontEnd/src/app/views/common/common.sass          |   4 +
 .../app/views/timeline-common/MarkdownPostEdit.tsx | 147 ++++++++++--------
 .../timeline-common/TimelinePostContentView.tsx    |   2 +-
 .../app/views/timeline-common/TimelinePostEdit.tsx | 167 ++++++++++-----------
 .../app/views/timeline-common/timeline-common.sass |   8 +
 8 files changed, 269 insertions(+), 170 deletions(-)
 create mode 100644 FrontEnd/src/app/views/common/TabPages.tsx

(limited to 'FrontEnd/src/app/views/timeline-common/MarkdownPostEdit.tsx')

diff --git a/FrontEnd/src/app/index.sass b/FrontEnd/src/app/index.sass
index 39dce693..60f274c2 100644
--- a/FrontEnd/src/app/index.sass
+++ b/FrontEnd/src/app/index.sass
@@ -95,3 +95,8 @@ textarea
 
 i
   line-height: 1
+
+.markdown-container
+  img
+    max-height: 200px
+    max-width: 100%
diff --git a/FrontEnd/src/app/services/TimelinePostBuilder.ts b/FrontEnd/src/app/services/TimelinePostBuilder.ts
index f6c8f881..92d8484b 100644
--- a/FrontEnd/src/app/services/TimelinePostBuilder.ts
+++ b/FrontEnd/src/app/services/TimelinePostBuilder.ts
@@ -1,8 +1,3 @@
-import {
-  escapeHtml,
-  replaceEntities,
-  unescapeMd,
-} from "remarkable/lib/common/utils";
 import { Remarkable } from "remarkable";
 
 import { UiLogicError } from "@/common";
@@ -18,28 +13,16 @@ export default class TimelinePostBuilder {
 
   constructor(onChange: () => void) {
     this._onChange = onChange;
+    const oldImageRenderer = this._md.renderer.rules.image;
     this._md.renderer.rules.image = ((
       _t: TimelinePostBuilder
     ): Remarkable.Rule<Remarkable.ImageToken, string> =>
       function (tokens, idx, options /*, env */) {
         const i = parseInt(tokens[idx].src);
-        const src =
-          ' src="' +
-          (isNaN(i) && i > 0 && i <= _t._images.length
-            ? escapeHtml(tokens[idx].src)
-            : _t._images[i - 1].url) +
-          '"';
-        const title = tokens[idx].title
-          ? ' title="' + escapeHtml(replaceEntities(tokens[idx].title)) + '"'
-          : "";
-        const alt =
-          ' alt="' +
-          (tokens[idx].alt
-            ? escapeHtml(replaceEntities(unescapeMd(tokens[idx].alt)))
-            : "") +
-          '"';
-        const suffix = options?.xhtmlOut ? " /" : "";
-        return "<img" + src + alt + title + suffix + ">";
+        if (!isNaN(i) && i > 0 && i <= _t._images.length) {
+          tokens[idx].src = _t._images[i - 1].url;
+        }
+        return oldImageRenderer(tokens, idx, options);
       })(this);
   }
 
@@ -49,6 +32,7 @@ export default class TimelinePostBuilder {
   }
 
   appendImage(file: File): void {
+    this._images = this._images.slice();
     this._images.push({
       file,
       url: URL.createObjectURL(file),
@@ -69,6 +53,8 @@ export default class TimelinePostBuilder {
       newIndex = this._images.length - 1;
     }
 
+    this._images = this._images.slice();
+
     const [old] = this._images.splice(oldIndex, 1);
     this._images.splice(newIndex, 0, old);
 
@@ -80,6 +66,8 @@ export default class TimelinePostBuilder {
       throw new UiLogicError("Old index out of range.");
     }
 
+    this._images = this._images.slice();
+
     URL.revokeObjectURL(this._images[index].url);
     this._images.splice(index, 1);
 
diff --git a/FrontEnd/src/app/views/common/TabPages.tsx b/FrontEnd/src/app/views/common/TabPages.tsx
new file mode 100644
index 00000000..424e769f
--- /dev/null
+++ b/FrontEnd/src/app/views/common/TabPages.tsx
@@ -0,0 +1,74 @@
+import React from "react";
+import { Nav } from "react-bootstrap";
+import { useTranslation } from "react-i18next";
+
+import { convertI18nText, I18nText, UiLogicError } from "@/common";
+
+export interface TabPage {
+  id: string;
+  tabText: I18nText;
+  page: React.ReactNode;
+}
+
+export interface TabPagesProps {
+  pages: TabPage[];
+  actions?: React.ReactNode;
+  className?: string;
+  style?: React.CSSProperties;
+  navClassName?: string;
+  navStyle?: React.CSSProperties;
+  pageContainerClassName?: string;
+  pageContainerStyle?: React.CSSProperties;
+}
+
+const TabPages: React.FC<TabPagesProps> = ({
+  pages,
+  actions,
+  className,
+  style,
+  navClassName,
+  navStyle,
+  pageContainerClassName,
+  pageContainerStyle,
+}) => {
+  if (pages.length === 0) {
+    throw new UiLogicError("Page list can't be empty.");
+  }
+
+  const { t } = useTranslation();
+
+  const [tab, setTab] = React.useState<string>(pages[0].id);
+
+  const currentPage = pages.find((p) => p.id === tab);
+
+  if (currentPage == null) {
+    throw new UiLogicError("Current tab value is bad.");
+  }
+
+  return (
+    <div className={className} style={style}>
+      <Nav variant="tabs" className={navClassName} style={navStyle}>
+        {pages.map((page) => (
+          <Nav.Item key={page.id}>
+            <Nav.Link
+              active={tab === page.id}
+              onClick={() => {
+                setTab(page.id);
+              }}
+            >
+              {convertI18nText(page.tabText, t)}
+            </Nav.Link>
+          </Nav.Item>
+        ))}
+        {actions != null && (
+          <div className="ml-auto cru-tab-pages-action-area">{actions}</div>
+        )}
+      </Nav>
+      <div className={pageContainerClassName} style={pageContainerStyle}>
+        {currentPage.page}
+      </div>
+    </div>
+  );
+};
+
+export default TabPages;
diff --git a/FrontEnd/src/app/views/common/common.sass b/FrontEnd/src/app/views/common/common.sass
index 0a30d995..f3022d19 100644
--- a/FrontEnd/src/app/views/common/common.sass
+++ b/FrontEnd/src/app/views/common/common.sass
@@ -92,3 +92,7 @@
 
 .cru-menu-divider
   border-top: 1px solid $gray-200
+
+.cru-tab-pages-action-area
+  display: flex
+  align-items: center
diff --git a/FrontEnd/src/app/views/timeline-common/MarkdownPostEdit.tsx b/FrontEnd/src/app/views/timeline-common/MarkdownPostEdit.tsx
index 079344e1..d43077b4 100644
--- a/FrontEnd/src/app/views/timeline-common/MarkdownPostEdit.tsx
+++ b/FrontEnd/src/app/views/timeline-common/MarkdownPostEdit.tsx
@@ -1,24 +1,31 @@
 import React from "react";
-import { Nav, Form } from "react-bootstrap";
+import { Form } from "react-bootstrap";
 import { useTranslation } from "react-i18next";
 
 import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline";
 
+import TabPages from "../common/TabPages";
 import TimelinePostBuilder from "@/services/TimelinePostBuilder";
 
 export interface MarkdownPostEditProps {
   timeline: string;
   onPosted: (post: HttpTimelinePostInfo) => void;
+  onPostError: () => void;
+  onClose: () => void;
+  className?: string;
+  style?: React.CSSProperties;
 }
 
 const MarkdownPostEdit: React.FC<MarkdownPostEditProps> = ({
   timeline: timelineName,
   onPosted,
+  onClose,
+  onPostError,
+  className,
+  style,
 }) => {
   const { t } = useTranslation();
 
-  const [tab, setTab] = React.useState<"text" | "images" | "preview">("text");
-
   const [process, setProcess] = React.useState<boolean>(false);
 
   const [text, _setText] = React.useState<string>("");
@@ -48,68 +55,88 @@ const MarkdownPostEdit: React.FC<MarkdownPostEditProps> = ({
   }, []);
 
   const send = async (): Promise<void> => {
-    const dataList = await getBuilder().build();
-    const post = await getHttpTimelineClient().postPost(timelineName, {
-      dataList,
-    });
-    onPosted(post);
+    setProcess(true);
+    try {
+      const dataList = await getBuilder().build();
+      const post = await getHttpTimelineClient().postPost(timelineName, {
+        dataList,
+      });
+      onPosted(post);
+      onClose();
+    } catch (e) {
+      setProcess(false);
+      onPostError();
+    }
   };
 
   return (
-    <div>
-      <Nav variant="tabs" className="my-2">
-        <Nav.Item>
-          <Nav.Link
-            active={tab === "text"}
-            onClick={() => {
-              setTab("text");
-            }}
-          >
-            {t("edit")}
-          </Nav.Link>
-        </Nav.Item>
-        <Nav.Item>
-          <Nav.Link
-            active={tab === "images"}
-            onClick={() => {
-              setTab("images");
-            }}
-          >
-            {t("image")}
-          </Nav.Link>
-        </Nav.Item>
-        <Nav.Item>
-          <Nav.Link
-            active={tab === "preview"}
-            onClick={() => {
-              setTab("preview");
-            }}
-          >
-            {t("preview")}
-          </Nav.Link>
-        </Nav.Item>
-      </Nav>
-      <div>
-        {(() => {
-          if (tab === "text") {
-            return (
-              <Form.Control
-                as="textarea"
-                value={text}
-                disabled={process}
-                onChange={(event) => {
-                  getBuilder().setMarkdownText(event.currentTarget.value);
+    <TabPages
+      className={className}
+      style={style}
+      pageContainerClassName="py-2"
+      actions={
+        <>
+          <div className="flat-button text-danger mr-2" onClick={onClose}>
+            {t("operationDialog.cancel")}
+          </div>
+          <div className="flat-button text-primary" onClick={send}>
+            {t("timeline.send")}
+          </div>
+        </>
+      }
+      pages={[
+        {
+          id: "text",
+          tabText: "edit",
+          page: (
+            <Form.Control
+              as="textarea"
+              value={text}
+              disabled={process}
+              onChange={(event) => {
+                getBuilder().setMarkdownText(event.currentTarget.value);
+              }}
+            />
+          ),
+        },
+        {
+          id: "images",
+          tabText: "image",
+          page: (
+            <div className="timeline-markdown-post-edit-page">
+              {images.map((image) => (
+                <img
+                  key={image.url}
+                  src={image.url}
+                  className="timeline-markdown-post-edit-image"
+                />
+              ))}
+              <Form.File
+                label={t("chooseImage")}
+                accept="image/*"
+                onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
+                  const { files } = event.currentTarget;
+                  if (files != null && files.length !== 0) {
+                    getBuilder().appendImage(files[0]);
+                  }
                 }}
+                disabled={process}
               />
-            );
-          } else if (tab === "images") {
-            return <div></div>;
-          } else {
-            return <div dangerouslySetInnerHTML={{ __html: previewHtml }} />;
-          }
-        })()}
-      </div>
-    </div>
+            </div>
+          ),
+        },
+        {
+          id: "preview",
+          tabText: "preview",
+          page: (
+            <div
+              className="markdown-container timeline-markdown-post-edit-page"
+              dangerouslySetInnerHTML={{ __html: previewHtml }}
+            />
+          ),
+        },
+      ]}
+    />
   );
 };
 
diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePostContentView.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePostContentView.tsx
index d836d1db..58fae4c7 100644
--- a/FrontEnd/src/app/views/timeline-common/TimelinePostContentView.tsx
+++ b/FrontEnd/src/app/views/timeline-common/TimelinePostContentView.tsx
@@ -152,7 +152,7 @@ const MarkdownView: React.FC<TimelinePostContentViewProps> = (props) => {
     }
     return (
       <div
-        className={className}
+        className={clsx(className, "markdown-container")}
         style={style}
         dangerouslySetInnerHTML={{
           __html: markdownHtml,
diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePostEdit.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePostEdit.tsx
index 025b41c0..a474d2f6 100644
--- a/FrontEnd/src/app/views/timeline-common/TimelinePostEdit.tsx
+++ b/FrontEnd/src/app/views/timeline-common/TimelinePostEdit.tsx
@@ -18,6 +18,7 @@ import { base64 } from "@/http/common";
 import BlobImage from "../common/BlobImage";
 import LoadingButton from "../common/LoadingButton";
 import { PopupMenu } from "../common/Menu";
+import MarkdownPostEdit from "./MarkdownPostEdit";
 
 interface TimelinePostEditTextProps {
   text: string;
@@ -121,26 +122,21 @@ const TimelinePostEdit: React.FC<TimelinePostEditProps> = (props) => {
 
   const [process, setProcess] = React.useState<boolean>(false);
 
-  const [kind, setKind] = React.useState<PostKind>("text");
+  const [kind, setKind] = React.useState<Exclude<PostKind, "markdown">>("text");
+  const [showMarkdown, setShowMarkdown] = React.useState<boolean>(false);
 
   const [text, setText] = React.useState<string>("");
-  const [markdown, setMarkdown] = React.useState<string>("");
   const [image, setImage] = React.useState<File | null>(null);
 
   const draftTextLocalStorageKey = `timeline.${timeline.name}.postDraft.text`;
-  const draftMarkdownLocalStorageKey = `timeline.${timeline.name}.postDraft.markdown`;
 
   React.useEffect(() => {
     setText(window.localStorage.getItem(draftTextLocalStorageKey) ?? "");
-    setMarkdown(
-      window.localStorage.getItem(draftMarkdownLocalStorageKey) ?? ""
-    );
-  }, [draftTextLocalStorageKey, draftMarkdownLocalStorageKey]);
+  }, [draftTextLocalStorageKey]);
 
   const canSend =
     (kind === "text" && text.length !== 0) ||
-    (kind === "image" && image != null) ||
-    (kind === "markdown" && markdown.length !== 0);
+    (kind === "image" && image != null);
 
   // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
   const containerRef = React.useRef<HTMLDivElement>(null!);
@@ -160,6 +156,13 @@ const TimelinePostEdit: React.FC<TimelinePostEditProps> = (props) => {
     };
   });
 
+  const onPostError = (): void => {
+    pushAlert({
+      type: "danger",
+      message: "timeline.sendPostFailed",
+    });
+  };
+
   const onSend = async (): Promise<void> => {
     setProcess(true);
 
@@ -171,12 +174,6 @@ const TimelinePostEdit: React.FC<TimelinePostEditProps> = (props) => {
           data: await base64(text),
         };
         break;
-      case "markdown":
-        requestData = {
-          contentType: "text/markdown",
-          data: await base64(markdown),
-        };
-        break;
       case "image":
         if (image == null) {
           throw new UiLogicError(
@@ -201,20 +198,14 @@ const TimelinePostEdit: React.FC<TimelinePostEditProps> = (props) => {
           if (kind === "text") {
             setText("");
             window.localStorage.removeItem(draftTextLocalStorageKey);
-          } else if (kind === "markdown") {
-            setMarkdown("");
-            window.localStorage.removeItem(draftMarkdownLocalStorageKey);
           }
           setProcess(false);
           setKind("text");
           onPosted(data);
         },
         (_) => {
-          pushAlert({
-            type: "danger",
-            message: "timeline.sendPostFailed",
-          });
           setProcess(false);
+          onPostError();
         }
       );
   };
@@ -224,73 +215,75 @@ const TimelinePostEdit: React.FC<TimelinePostEditProps> = (props) => {
       ref={containerRef}
       className={clsx("container-fluid bg-light", className)}
     >
-      <Row>
-        <Col className="px-1 py-1">
-          {(() => {
-            if (kind === "text") {
-              return (
-                <TimelinePostEditText
-                  className="w-100 h-100 timeline-post-edit"
-                  text={text}
-                  disabled={process}
-                  onChange={(t) => {
-                    setText(t);
-                    window.localStorage.setItem(draftTextLocalStorageKey, t);
-                  }}
-                />
-              );
-            } else if (kind === "image") {
-              return (
-                <TimelinePostEditImage onSelect={setImage} disabled={process} />
-              );
-            } else if (kind === "markdown") {
-              return (
-                <TimelinePostEditText
-                  className="w-100 h-100 timeline-post-edit"
-                  text={markdown}
-                  disabled={process}
-                  onChange={(t) => {
-                    setMarkdown(t);
-                    window.localStorage.setItem(
-                      draftMarkdownLocalStorageKey,
-                      t
-                    );
-                  }}
+      {showMarkdown ? (
+        <MarkdownPostEdit
+          className="w-100"
+          onClose={() => setShowMarkdown(false)}
+          timeline={timeline.name}
+          onPosted={onPosted}
+          onPostError={onPostError}
+        />
+      ) : (
+        <Row>
+          <Col className="px-1 py-1">
+            {(() => {
+              if (kind === "text") {
+                return (
+                  <TimelinePostEditText
+                    className="w-100 h-100 timeline-post-edit"
+                    text={text}
+                    disabled={process}
+                    onChange={(t) => {
+                      setText(t);
+                      window.localStorage.setItem(draftTextLocalStorageKey, t);
+                    }}
+                  />
+                );
+              } else if (kind === "image") {
+                return (
+                  <TimelinePostEditImage
+                    onSelect={setImage}
+                    disabled={process}
+                  />
+                );
+              }
+            })()}
+          </Col>
+          <Col xs="auto" className="align-self-end m-1">
+            <div className="d-block text-center mt-1 mb-2">
+              <PopupMenu
+                items={(["text", "image", "markdown"] as const).map((kind) => ({
+                  type: "button",
+                  text: `timeline.post.type.${kind}`,
+                  iconClassName: postKindIconClassNameMap[kind],
+                  onClick: () => {
+                    if (kind === "markdown") {
+                      setShowMarkdown(true);
+                    } else {
+                      setKind(kind);
+                    }
+                  },
+                }))}
+              >
+                <i
+                  className={clsx(
+                    postKindIconClassNameMap[kind],
+                    "icon-button large"
+                  )}
                 />
-              );
-            }
-          })()}
-        </Col>
-        <Col xs="auto" className="align-self-end m-1">
-          <div className="d-block text-center mt-1 mb-2">
-            <PopupMenu
-              items={(["text", "image", "markdown"] as const).map((kind) => ({
-                type: "button",
-                text: `timeline.post.type.${kind}`,
-                iconClassName: postKindIconClassNameMap[kind],
-                onClick: () => {
-                  setKind(kind);
-                },
-              }))}
+              </PopupMenu>
+            </div>
+            <LoadingButton
+              variant="primary"
+              onClick={onSend}
+              disabled={!canSend}
+              loading={process}
             >
-              <i
-                className={clsx(
-                  postKindIconClassNameMap[kind],
-                  "icon-button large"
-                )}
-              />
-            </PopupMenu>
-          </div>
-          <LoadingButton
-            variant="primary"
-            onClick={onSend}
-            disabled={!canSend}
-            loading={process}
-          >
-            {t("timeline.send")}
-          </LoadingButton>
-        </Col>
-      </Row>
+              {t("timeline.send")}
+            </LoadingButton>
+          </Col>
+        </Row>
+      )}
     </div>
   );
 };
diff --git a/FrontEnd/src/app/views/timeline-common/timeline-common.sass b/FrontEnd/src/app/views/timeline-common/timeline-common.sass
index 43c4d0f7..04318674 100644
--- a/FrontEnd/src/app/views/timeline-common/timeline-common.sass
+++ b/FrontEnd/src/app/views/timeline-common/timeline-common.sass
@@ -170,3 +170,11 @@ $timeline-line-color-current: #36c2e6
   top: 56px
   right: 0
   margin: 0.5em
+
+.timeline-markdown-post-edit-page
+  overflow: scroll
+  max-height: 300px
+
+.timeline-markdown-post-edit-image
+  max-width: 100%
+  max-height: 200px
-- 
cgit v1.2.3


From 36145a18463e0ad4fb372df82cd488f1b4dae2f7 Mon Sep 17 00:00:00 2001
From: crupest <crupest@outlook.com>
Date: Thu, 18 Mar 2021 21:17:12 +0800
Subject: feat: Prevent leave.

---
 FrontEnd/src/app/locales/en/translation.json       |   1 +
 FrontEnd/src/app/locales/zh/translation.json       |   1 +
 FrontEnd/src/app/services/TimelinePostBuilder.ts   |   4 +
 .../app/views/timeline-common/MarkdownPostEdit.tsx | 149 ++++++++++++---------
 4 files changed, 90 insertions(+), 65 deletions(-)

(limited to 'FrontEnd/src/app/views/timeline-common/MarkdownPostEdit.tsx')

diff --git a/FrontEnd/src/app/locales/en/translation.json b/FrontEnd/src/app/locales/en/translation.json
index 5613668e..8fcd5bcd 100644
--- a/FrontEnd/src/app/locales/en/translation.json
+++ b/FrontEnd/src/app/locales/en/translation.json
@@ -67,6 +67,7 @@
     "send": "Send",
     "deletePostFailed": "Failed to delete post.",
     "sendPostFailed": "Failed to send post.",
+    "confirmLeave": "Are you sure to leave? All content you typed would be lost.",
     "visibility": {
       "public": "public to everyone",
       "register": "only registed people can see",
diff --git a/FrontEnd/src/app/locales/zh/translation.json b/FrontEnd/src/app/locales/zh/translation.json
index c73f2876..f03c4c03 100644
--- a/FrontEnd/src/app/locales/zh/translation.json
+++ b/FrontEnd/src/app/locales/zh/translation.json
@@ -67,6 +67,7 @@
     "send": "发送",
     "deletePostFailed": "删除消息失败。",
     "sendPostFailed": "发送消息失败。",
+    "confirmLeave":"确定要离开吗？所有输入的内容将会丢失。",
     "visibility": {
       "public": "对所有人公开",
       "register": "仅注册可见",
diff --git a/FrontEnd/src/app/services/TimelinePostBuilder.ts b/FrontEnd/src/app/services/TimelinePostBuilder.ts
index 92d8484b..40279eca 100644
--- a/FrontEnd/src/app/services/TimelinePostBuilder.ts
+++ b/FrontEnd/src/app/services/TimelinePostBuilder.ts
@@ -82,6 +82,10 @@ export default class TimelinePostBuilder {
     return this._images;
   }
 
+  get isEmpty(): boolean {
+    return this._text.length === 0 && this._images.length === 0;
+  }
+
   renderHtml(): string {
     return this._md.render(this._text);
   }
diff --git a/FrontEnd/src/app/views/timeline-common/MarkdownPostEdit.tsx b/FrontEnd/src/app/views/timeline-common/MarkdownPostEdit.tsx
index d43077b4..ab6aafea 100644
--- a/FrontEnd/src/app/views/timeline-common/MarkdownPostEdit.tsx
+++ b/FrontEnd/src/app/views/timeline-common/MarkdownPostEdit.tsx
@@ -1,6 +1,7 @@
 import React from "react";
 import { Form } from "react-bootstrap";
 import { useTranslation } from "react-i18next";
+import { Prompt } from "react-router";
 
 import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline";
 
@@ -26,6 +27,8 @@ const MarkdownPostEdit: React.FC<MarkdownPostEditProps> = ({
 }) => {
   const { t } = useTranslation();
 
+  const [canLeave, setCanLeave] = React.useState<boolean>(true);
+
   const [process, setProcess] = React.useState<boolean>(false);
 
   const [text, _setText] = React.useState<string>("");
@@ -39,6 +42,7 @@ const MarkdownPostEdit: React.FC<MarkdownPostEditProps> = ({
   const getBuilder = (): TimelinePostBuilder => {
     if (_builder.current == null) {
       const builder = new TimelinePostBuilder(() => {
+        setCanLeave(builder.isEmpty);
         _setText(builder.text);
         _setImages(builder.images);
         _setPreviewHtml(builder.renderHtml());
@@ -54,6 +58,18 @@ const MarkdownPostEdit: React.FC<MarkdownPostEditProps> = ({
     };
   }, []);
 
+  React.useEffect(() => {
+    window.onbeforeunload = () => {
+      if (!canLeave) {
+        return t("timeline.confirmLeave");
+      }
+    };
+
+    return () => {
+      window.onbeforeunload = null;
+    };
+  }, [canLeave, t]);
+
   const send = async (): Promise<void> => {
     setProcess(true);
     try {
@@ -70,73 +86,76 @@ const MarkdownPostEdit: React.FC<MarkdownPostEditProps> = ({
   };
 
   return (
-    <TabPages
-      className={className}
-      style={style}
-      pageContainerClassName="py-2"
-      actions={
-        <>
-          <div className="flat-button text-danger mr-2" onClick={onClose}>
-            {t("operationDialog.cancel")}
-          </div>
-          <div className="flat-button text-primary" onClick={send}>
-            {t("timeline.send")}
-          </div>
-        </>
-      }
-      pages={[
-        {
-          id: "text",
-          tabText: "edit",
-          page: (
-            <Form.Control
-              as="textarea"
-              value={text}
-              disabled={process}
-              onChange={(event) => {
-                getBuilder().setMarkdownText(event.currentTarget.value);
-              }}
-            />
-          ),
-        },
-        {
-          id: "images",
-          tabText: "image",
-          page: (
-            <div className="timeline-markdown-post-edit-page">
-              {images.map((image) => (
-                <img
-                  key={image.url}
-                  src={image.url}
-                  className="timeline-markdown-post-edit-image"
-                />
-              ))}
-              <Form.File
-                label={t("chooseImage")}
-                accept="image/*"
-                onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
-                  const { files } = event.currentTarget;
-                  if (files != null && files.length !== 0) {
-                    getBuilder().appendImage(files[0]);
-                  }
-                }}
+    <>
+      <Prompt when={!canLeave} message={t("timeline.confirmLeave")} />
+      <TabPages
+        className={className}
+        style={style}
+        pageContainerClassName="py-2"
+        actions={
+          <>
+            <div className="flat-button text-danger mr-2" onClick={onClose}>
+              {t("operationDialog.cancel")}
+            </div>
+            <div className="flat-button text-primary" onClick={send}>
+              {t("timeline.send")}
+            </div>
+          </>
+        }
+        pages={[
+          {
+            id: "text",
+            tabText: "edit",
+            page: (
+              <Form.Control
+                as="textarea"
+                value={text}
                 disabled={process}
+                onChange={(event) => {
+                  getBuilder().setMarkdownText(event.currentTarget.value);
+                }}
               />
-            </div>
-          ),
-        },
-        {
-          id: "preview",
-          tabText: "preview",
-          page: (
-            <div
-              className="markdown-container timeline-markdown-post-edit-page"
-              dangerouslySetInnerHTML={{ __html: previewHtml }}
-            />
-          ),
-        },
-      ]}
-    />
+            ),
+          },
+          {
+            id: "images",
+            tabText: "image",
+            page: (
+              <div className="timeline-markdown-post-edit-page">
+                {images.map((image) => (
+                  <img
+                    key={image.url}
+                    src={image.url}
+                    className="timeline-markdown-post-edit-image"
+                  />
+                ))}
+                <Form.File
+                  label={t("chooseImage")}
+                  accept="image/*"
+                  onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
+                    const { files } = event.currentTarget;
+                    if (files != null && files.length !== 0) {
+                      getBuilder().appendImage(files[0]);
+                    }
+                  }}
+                  disabled={process}
+                />
+              </div>
+            ),
+          },
+          {
+            id: "preview",
+            tabText: "preview",
+            page: (
+              <div
+                className="markdown-container timeline-markdown-post-edit-page"
+                dangerouslySetInnerHTML={{ __html: previewHtml }}
+              />
+            ),
+          },
+        ]}
+      />
+    </>
   );
 };
 
-- 
cgit v1.2.3


From cf161b23fb1eaecec1c4b4ab3cf5ec620cd897bc Mon Sep 17 00:00:00 2001
From: crupest <crupest@outlook.com>
Date: Thu, 18 Mar 2021 21:28:30 +0800
Subject: feat: Prevent leave.

---
 FrontEnd/src/app/locales/en/translation.json       |  1 +
 FrontEnd/src/app/locales/zh/translation.json       |  3 +-
 FrontEnd/src/app/views/common/ConfirmDialog.tsx    | 40 ++++++++++++++++++++++
 .../app/views/timeline-common/MarkdownPostEdit.tsx | 25 +++++++++++++-
 4 files changed, 67 insertions(+), 2 deletions(-)
 create mode 100644 FrontEnd/src/app/views/common/ConfirmDialog.tsx

(limited to 'FrontEnd/src/app/views/timeline-common/MarkdownPostEdit.tsx')

diff --git a/FrontEnd/src/app/locales/en/translation.json b/FrontEnd/src/app/locales/en/translation.json
index 8fcd5bcd..65ddbe0c 100644
--- a/FrontEnd/src/app/locales/en/translation.json
+++ b/FrontEnd/src/app/locales/en/translation.json
@@ -67,6 +67,7 @@
     "send": "Send",
     "deletePostFailed": "Failed to delete post.",
     "sendPostFailed": "Failed to send post.",
+    "dropDraft": "Drop Draft",
     "confirmLeave": "Are you sure to leave? All content you typed would be lost.",
     "visibility": {
       "public": "public to everyone",
diff --git a/FrontEnd/src/app/locales/zh/translation.json b/FrontEnd/src/app/locales/zh/translation.json
index f03c4c03..f6971241 100644
--- a/FrontEnd/src/app/locales/zh/translation.json
+++ b/FrontEnd/src/app/locales/zh/translation.json
@@ -67,7 +67,8 @@
     "send": "发送",
     "deletePostFailed": "删除消息失败。",
     "sendPostFailed": "发送消息失败。",
-    "confirmLeave":"确定要离开吗？所有输入的内容将会丢失。",
+    "dropDraft": "放弃草稿",
+    "confirmLeave": "确定要离开吗？所有输入的内容将会丢失。",
     "visibility": {
       "public": "对所有人公开",
       "register": "仅注册可见",
diff --git a/FrontEnd/src/app/views/common/ConfirmDialog.tsx b/FrontEnd/src/app/views/common/ConfirmDialog.tsx
new file mode 100644
index 00000000..72940c51
--- /dev/null
+++ b/FrontEnd/src/app/views/common/ConfirmDialog.tsx
@@ -0,0 +1,40 @@
+import { convertI18nText, I18nText } from "@/common";
+import React from "react";
+import { Modal, Button } from "react-bootstrap";
+import { useTranslation } from "react-i18next";
+
+const ConfirmDialog: React.FC<{
+  onClose: () => void;
+  onConfirm: () => void;
+  title: I18nText;
+  body: I18nText;
+}> = ({ onClose, onConfirm, title, body }) => {
+  const { t } = useTranslation();
+
+  return (
+    <Modal onHide={onClose} show centered>
+      <Modal.Header>
+        <Modal.Title className="text-danger">
+          {convertI18nText(title, t)}
+        </Modal.Title>
+      </Modal.Header>
+      <Modal.Body>{convertI18nText(body, t)}</Modal.Body>
+      <Modal.Footer>
+        <Button variant="secondary" onClick={onClose}>
+          {t("operationDialog.cancel")}
+        </Button>
+        <Button
+          variant="danger"
+          onClick={() => {
+            onConfirm();
+            onClose();
+          }}
+        >
+          {t("operationDialog.confirm")}
+        </Button>
+      </Modal.Footer>
+    </Modal>
+  );
+};
+
+export default ConfirmDialog;
diff --git a/FrontEnd/src/app/views/timeline-common/MarkdownPostEdit.tsx b/FrontEnd/src/app/views/timeline-common/MarkdownPostEdit.tsx
index ab6aafea..bad6b2b0 100644
--- a/FrontEnd/src/app/views/timeline-common/MarkdownPostEdit.tsx
+++ b/FrontEnd/src/app/views/timeline-common/MarkdownPostEdit.tsx
@@ -7,6 +7,7 @@ import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline";
 
 import TabPages from "../common/TabPages";
 import TimelinePostBuilder from "@/services/TimelinePostBuilder";
+import ConfirmDialog from "../common/ConfirmDialog";
 
 export interface MarkdownPostEditProps {
   timeline: string;
@@ -31,6 +32,11 @@ const MarkdownPostEdit: React.FC<MarkdownPostEditProps> = ({
 
   const [process, setProcess] = React.useState<boolean>(false);
 
+  const [
+    showLeaveConfirmDialog,
+    setShowLeaveConfirmDialog,
+  ] = React.useState<boolean>(false);
+
   const [text, _setText] = React.useState<string>("");
   const [images, _setImages] = React.useState<{ file: File; url: string }[]>(
     []
@@ -94,7 +100,16 @@ const MarkdownPostEdit: React.FC<MarkdownPostEditProps> = ({
         pageContainerClassName="py-2"
         actions={
           <>
-            <div className="flat-button text-danger mr-2" onClick={onClose}>
+            <div
+              className="flat-button text-danger mr-2"
+              onClick={() => {
+                if (canLeave) {
+                  onClose();
+                } else {
+                  setShowLeaveConfirmDialog(true);
+                }
+              }}
+            >
               {t("operationDialog.cancel")}
             </div>
             <div className="flat-button text-primary" onClick={send}>
@@ -155,6 +170,14 @@ const MarkdownPostEdit: React.FC<MarkdownPostEditProps> = ({
           },
         ]}
       />
+      {showLeaveConfirmDialog && (
+        <ConfirmDialog
+          onClose={() => setShowLeaveConfirmDialog(false)}
+          onConfirm={onClose}
+          title="timeline.dropDraft"
+          body="timeline.confirmLeave"
+        />
+      )}
     </>
   );
 };
-- 
cgit v1.2.3


From 4aecd115b435b3217329824ddf54c87b32e77b89 Mon Sep 17 00:00:00 2001
From: crupest <crupest@outlook.com>
Date: Thu, 18 Mar 2021 21:41:11 +0800
Subject: feat: Prevent send empty.

---
 FrontEnd/src/app/index.sass                        | 17 +++++++---
 FrontEnd/src/app/views/common/FlatButton.tsx       | 36 ++++++++++++++++++++++
 .../app/views/timeline-common/MarkdownPostEdit.tsx | 12 +++++---
 3 files changed, 55 insertions(+), 10 deletions(-)
 create mode 100644 FrontEnd/src/app/views/common/FlatButton.tsx

(limited to 'FrontEnd/src/app/views/timeline-common/MarkdownPostEdit.tsx')

diff --git a/FrontEnd/src/app/index.sass b/FrontEnd/src/app/index.sass
index 60f274c2..2079cad8 100644
--- a/FrontEnd/src/app/index.sass
+++ b/FrontEnd/src/app/index.sass
@@ -45,8 +45,15 @@ small
   cursor: pointer
   padding: 0.2em 0.5em
   border-radius: 0.2em
-  &:hover
+  &:hover:not(.disabled)
     background-color: $gray-200
+  &.disabled
+    cursor: default
+  @each $color, $value in $theme-colors
+    &.#{$color}
+      color: $value
+      &.disabled
+        color: adjust-color($value, $lightness: +15%)
 
 .cursor-pointer
   cursor: pointer
@@ -81,10 +88,10 @@ textarea
 .text-yellow
   color: $yellow
 
-@each $color, $value in $theme-colors
-  .text-button
-    background: transparent
-    border: none
+.text-button
+  background: transparent
+  border: none
+  @each $color, $value in $theme-colors
     &.#{$color}
       color: $value
       &:hover
diff --git a/FrontEnd/src/app/views/common/FlatButton.tsx b/FrontEnd/src/app/views/common/FlatButton.tsx
new file mode 100644
index 00000000..80bb654c
--- /dev/null
+++ b/FrontEnd/src/app/views/common/FlatButton.tsx
@@ -0,0 +1,36 @@
+import React from "react";
+import clsx from "clsx";
+
+import { BootstrapThemeColor } from "@/common";
+
+export interface FlatButtonProps {
+  variant?: BootstrapThemeColor | string;
+  disabled?: boolean;
+  className?: string;
+  style?: React.CSSProperties;
+  onClick?: () => void;
+}
+
+const FlatButton: React.FC<FlatButtonProps> = (props) => {
+  const { disabled, className, style } = props;
+  const variant = props.variant ?? "primary";
+
+  const onClick = disabled ? undefined : props.onClick;
+
+  return (
+    <div
+      className={clsx(
+        "flat-button",
+        variant,
+        disabled ? "disabled" : null,
+        className
+      )}
+      style={style}
+      onClick={onClick}
+    >
+      {props.children}
+    </div>
+  );
+};
+
+export default FlatButton;
diff --git a/FrontEnd/src/app/views/timeline-common/MarkdownPostEdit.tsx b/FrontEnd/src/app/views/timeline-common/MarkdownPostEdit.tsx
index bad6b2b0..f4351db0 100644
--- a/FrontEnd/src/app/views/timeline-common/MarkdownPostEdit.tsx
+++ b/FrontEnd/src/app/views/timeline-common/MarkdownPostEdit.tsx
@@ -5,6 +5,7 @@ import { Prompt } from "react-router";
 
 import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline";
 
+import FlatButton from "../common/FlatButton";
 import TabPages from "../common/TabPages";
 import TimelinePostBuilder from "@/services/TimelinePostBuilder";
 import ConfirmDialog from "../common/ConfirmDialog";
@@ -100,8 +101,9 @@ const MarkdownPostEdit: React.FC<MarkdownPostEditProps> = ({
         pageContainerClassName="py-2"
         actions={
           <>
-            <div
-              className="flat-button text-danger mr-2"
+            <FlatButton
+              className="mr-2"
+              variant="danger"
               onClick={() => {
                 if (canLeave) {
                   onClose();
@@ -111,10 +113,10 @@ const MarkdownPostEdit: React.FC<MarkdownPostEditProps> = ({
               }}
             >
               {t("operationDialog.cancel")}
-            </div>
-            <div className="flat-button text-primary" onClick={send}>
+            </FlatButton>
+            <FlatButton onClick={send} disabled={canLeave}>
               {t("timeline.send")}
-            </div>
+            </FlatButton>
           </>
         }
         pages={[
-- 
cgit v1.2.3


From 4b07846bf16bbf90a5869219ce9b521367c28506 Mon Sep 17 00:00:00 2001
From: crupest <crupest@outlook.com>
Date: Thu, 18 Mar 2021 21:59:16 +0800
Subject: feat: Delete image.

---
 .../app/views/timeline-common/MarkdownPostEdit.tsx   | 20 +++++++++++++++-----
 .../app/views/timeline-common/timeline-common.sass   | 10 ++++++++++
 2 files changed, 25 insertions(+), 5 deletions(-)

(limited to 'FrontEnd/src/app/views/timeline-common/MarkdownPostEdit.tsx')

diff --git a/FrontEnd/src/app/views/timeline-common/MarkdownPostEdit.tsx b/FrontEnd/src/app/views/timeline-common/MarkdownPostEdit.tsx
index f4351db0..68dee848 100644
--- a/FrontEnd/src/app/views/timeline-common/MarkdownPostEdit.tsx
+++ b/FrontEnd/src/app/views/timeline-common/MarkdownPostEdit.tsx
@@ -139,12 +139,22 @@ const MarkdownPostEdit: React.FC<MarkdownPostEditProps> = ({
             tabText: "image",
             page: (
               <div className="timeline-markdown-post-edit-page">
-                {images.map((image) => (
-                  <img
+                {images.map((image, index) => (
+                  <div
                     key={image.url}
-                    src={image.url}
-                    className="timeline-markdown-post-edit-image"
-                  />
+                    className="timeline-markdown-post-edit-image-container"
+                  >
+                    <img
+                      src={image.url}
+                      className="timeline-markdown-post-edit-image"
+                    />
+                    <i
+                      className="bi-trash text-danger icon-button timeline-markdown-post-edit-image-delete-button"
+                      onClick={() => {
+                        getBuilder().deleteImage(index);
+                      }}
+                    />
+                  </div>
                 ))}
                 <Form.File
                   label={t("chooseImage")}
diff --git a/FrontEnd/src/app/views/timeline-common/timeline-common.sass b/FrontEnd/src/app/views/timeline-common/timeline-common.sass
index 04318674..0d172327 100644
--- a/FrontEnd/src/app/views/timeline-common/timeline-common.sass
+++ b/FrontEnd/src/app/views/timeline-common/timeline-common.sass
@@ -175,6 +175,16 @@ $timeline-line-color-current: #36c2e6
   overflow: scroll
   max-height: 300px
 
+.timeline-markdown-post-edit-image-container
+  position: relative
+  text-align: center
+  margin-bottom: 1em
+
 .timeline-markdown-post-edit-image
   max-width: 100%
   max-height: 200px
+
+.timeline-markdown-post-edit-image-delete-button
+  position: absolute
+  right: 10px
+  top: 2px
-- 
cgit v1.2.3


From e46f97d5b1a7573909c88b87e7cf2298e1c5dfa0 Mon Sep 17 00:00:00 2001
From: crupest <crupest@outlook.com>
Date: Thu, 18 Mar 2021 22:04:04 +0800
Subject: feat: Only support image types.

---
 FrontEnd/src/app/views/timeline-common/MarkdownPostEdit.tsx | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

(limited to 'FrontEnd/src/app/views/timeline-common/MarkdownPostEdit.tsx')

diff --git a/FrontEnd/src/app/views/timeline-common/MarkdownPostEdit.tsx b/FrontEnd/src/app/views/timeline-common/MarkdownPostEdit.tsx
index 68dee848..fb38d2f7 100644
--- a/FrontEnd/src/app/views/timeline-common/MarkdownPostEdit.tsx
+++ b/FrontEnd/src/app/views/timeline-common/MarkdownPostEdit.tsx
@@ -158,7 +158,7 @@ const MarkdownPostEdit: React.FC<MarkdownPostEditProps> = ({
                 ))}
                 <Form.File
                   label={t("chooseImage")}
-                  accept="image/*"
+                  accept="image/jpeg,image/jpg,image/png,image/gif,image/webp"
                   onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
                     const { files } = event.currentTarget;
                     if (files != null && files.length !== 0) {
-- 
cgit v1.2.3

