From 40b4871c3f7bfe04f332ae7fb687fd7d9ae34734 Mon Sep 17 00:00:00 2001
From: crupest <crupest@outlook.com>
Date: Thu, 14 Sep 2023 23:47:16 +0800
Subject: ...

---
 FrontEnd/src/pages/timeline/edit/ImagePostEdit.tsx | 36 ++++++++++++++++++++++
 1 file changed, 36 insertions(+)
 create mode 100644 FrontEnd/src/pages/timeline/edit/ImagePostEdit.tsx

(limited to 'FrontEnd/src/pages/timeline/edit/ImagePostEdit.tsx')

diff --git a/FrontEnd/src/pages/timeline/edit/ImagePostEdit.tsx b/FrontEnd/src/pages/timeline/edit/ImagePostEdit.tsx
new file mode 100644
index 00000000..d25d04b4
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/edit/ImagePostEdit.tsx
@@ -0,0 +1,36 @@
+import classNames from "classnames";
+
+import BlobImage from "~/src/components/BlobImage";
+
+interface TimelinePostEditImageProps {
+  file: File;
+  onChange: (file: File | null) => void;
+  disabled: boolean;
+  className?: string;
+}
+
+export default function ImagePostEdit(props: TimelinePostEditImageProps) {
+  const { file, onChange, disabled, className } = props;
+
+  return (
+    <div
+      className={classNames("timeline-post-create-edit-container", className)}
+    >
+      <input
+        type="file"
+        accept="image/*"
+        disabled={disabled}
+        onChange={(e) => {
+          const files = e.target.files;
+          if (files == null || files.length === 0) {
+            onChange(null);
+          } else {
+            onChange(files[0]);
+          }
+        }}
+        className="mx-3 my-1"
+      />
+      {file && <BlobImage src={file} className="timeline-post-create-image" />}
+    </div>
+  );
+}
-- 
cgit v1.2.3


From 754597d49cd2d3f6295e5fe3ed68c6210bf4e8a5 Mon Sep 17 00:00:00 2001
From: crupest <crupest@outlook.com>
Date: Thu, 14 Sep 2023 18:58:44 +0800
Subject: Fix mobile post create.

---
 FrontEnd/src/components/common.ts                  |   3 +
 FrontEnd/src/components/hooks/index.ts             |   1 +
 FrontEnd/src/components/hooks/useWindowLeave.ts    |  22 ++
 FrontEnd/src/pages/timeline/edit/ImagePostEdit.css |   2 +-
 FrontEnd/src/pages/timeline/edit/ImagePostEdit.tsx |   6 +-
 .../src/pages/timeline/edit/MarkdownPostEdit.tsx   | 369 ++++++++++-----------
 .../src/pages/timeline/edit/PlainTextPostEdit.css  |   7 +-
 .../src/pages/timeline/edit/PlainTextPostEdit.tsx  |  11 +-
 .../pages/timeline/edit/TimelinePostCreateView.tsx | 223 ++++++-------
 FrontEnd/src/services/TimelinePostBuilder.ts       | 128 -------
 FrontEnd/src/utilities/array.ts                    |  41 +++
 11 files changed, 366 insertions(+), 447 deletions(-)
 create mode 100644 FrontEnd/src/components/hooks/useWindowLeave.ts
 delete mode 100644 FrontEnd/src/services/TimelinePostBuilder.ts
 create mode 100644 FrontEnd/src/utilities/array.ts

(limited to 'FrontEnd/src/pages/timeline/edit/ImagePostEdit.tsx')

diff --git a/FrontEnd/src/components/common.ts b/FrontEnd/src/components/common.ts
index b9b55f9b..a6c3e705 100644
--- a/FrontEnd/src/components/common.ts
+++ b/FrontEnd/src/components/common.ts
@@ -17,3 +17,6 @@ export type ClickableColor = ThemeColor | "grayscale" | "light" | "minor";
 export { breakpoints } from "./breakpoints";
 
 export * as geometry from "~src/utilities/geometry";
+
+export * as array from "~src/utilities/array"
+
diff --git a/FrontEnd/src/components/hooks/index.ts b/FrontEnd/src/components/hooks/index.ts
index 3c9859bc..771b0e2a 100644
--- a/FrontEnd/src/components/hooks/index.ts
+++ b/FrontEnd/src/components/hooks/index.ts
@@ -1,3 +1,4 @@
 export { useMobile } from "./responsive";
 export { default as useClickOutside } from "./useClickOutside";
 export { default as useScrollToBottom } from "./useScrollToBottom";
+export { default as useWindowLeave } from "./useWindowLeave";
diff --git a/FrontEnd/src/components/hooks/useWindowLeave.ts b/FrontEnd/src/components/hooks/useWindowLeave.ts
new file mode 100644
index 00000000..08777e30
--- /dev/null
+++ b/FrontEnd/src/components/hooks/useWindowLeave.ts
@@ -0,0 +1,22 @@
+import { useEffect } from "react";
+
+import { useC, Text } from "../common";
+
+export default function useWindowLeave(
+  allow: boolean,
+  message: Text = "timeline.confirmLeave",
+) {
+  const c = useC();
+
+  useEffect(() => {
+    if (!allow) {
+      window.onbeforeunload = () => {
+        return c(message);
+      };
+
+      return () => {
+        window.onbeforeunload = null;
+      };
+    }
+  }, [allow, message]);
+}
diff --git a/FrontEnd/src/pages/timeline/edit/ImagePostEdit.css b/FrontEnd/src/pages/timeline/edit/ImagePostEdit.css
index 3d5e895c..df7a6af6 100644
--- a/FrontEnd/src/pages/timeline/edit/ImagePostEdit.css
+++ b/FrontEnd/src/pages/timeline/edit/ImagePostEdit.css
@@ -1,4 +1,4 @@
-.timeline-post-create-image {
+.timeline-edit-image {
   max-width: 100px;
   max-height: 100px;
 }
diff --git a/FrontEnd/src/pages/timeline/edit/ImagePostEdit.tsx b/FrontEnd/src/pages/timeline/edit/ImagePostEdit.tsx
index d25d04b4..4676e45a 100644
--- a/FrontEnd/src/pages/timeline/edit/ImagePostEdit.tsx
+++ b/FrontEnd/src/pages/timeline/edit/ImagePostEdit.tsx
@@ -3,7 +3,7 @@ import classNames from "classnames";
 import BlobImage from "~/src/components/BlobImage";
 
 interface TimelinePostEditImageProps {
-  file: File;
+  file: File | null;
   onChange: (file: File | null) => void;
   disabled: boolean;
   className?: string;
@@ -14,7 +14,7 @@ export default function ImagePostEdit(props: TimelinePostEditImageProps) {
 
   return (
     <div
-      className={classNames("timeline-post-create-edit-container", className)}
+      className={classNames("timeline-edit-image-container", className)}
     >
       <input
         type="file"
@@ -30,7 +30,7 @@ export default function ImagePostEdit(props: TimelinePostEditImageProps) {
         }}
         className="mx-3 my-1"
       />
-      {file && <BlobImage src={file} className="timeline-post-create-image" />}
+      {file && <BlobImage src={file} className="timeline-edit-image" />}
     </div>
   );
 }
diff --git a/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.tsx b/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.tsx
index d10d3f2d..0dfaf33e 100644
--- a/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.tsx
+++ b/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.tsx
@@ -1,216 +1,201 @@
-import * as React from "react";
+import { useEffect, useState } from "react";
 import classnames from "classnames";
-import { useTranslation } from "react-i18next";
+import { marked } from "marked";
 
-import {
-  getHttpTimelineClient,
-  HttpTimelinePostInfo,
-} from "~src/http/timeline";
+import { HttpTimelinePostPostRequestData } from "~src/http/timeline";
 
-import TimelinePostBuilder from "~src/services/TimelinePostBuilder";
+import base64 from "~src/utilities/base64";
 
-import FlatButton from "~src/components/button/FlatButton";
+import { array } from "~src/components/common";
 import { TabPages } from "~src/components/tab";
-import ConfirmDialog from "~src/components/dialog/ConfirmDialog";
-import Spinner from "~src/components/Spinner";
-import IconButton from "~src/components/button/IconButton";
-import { DialogProvider, useDialog } from "~src/components/dialog";
+import { IconButton } from "~src/components/button";
+import BlobImage from "~src/components/BlobImage";
 
 import "./MarkdownPostEdit.css";
 
-export interface MarkdownPostEditProps {
-  owner: string;
-  timeline: string;
-  onPosted: (post: HttpTimelinePostInfo) => void;
-  onPostError: () => void;
-  onClose: () => void;
-  className?: string;
+class MarkedRenderer extends marked.Renderer {
+  constructor(public images: string[]) {
+    super();
+  }
+
+  // Custom image parser for indexed image link.
+  image(href: string | null, title: string | null, text: string): string {
+    if (href != null) {
+      const i = parseInt(href);
+      if (!isNaN(i) && i > 0 && i <= this.images.length) {
+        href = this.images[i - 1];
+      }
+    }
+
+    return this.image(href, title, text);
+  }
 }
 
-const MarkdownPostEdit: React.FC<MarkdownPostEditProps> = ({
-  owner: ownerUsername,
-  timeline: timelineName,
-  onPosted,
-  onClose,
-  onPostError,
-  className,
-}) => {
-  const { t } = useTranslation();
-
-  const [canLeave, setCanLeave] = React.useState<boolean>(true);
-
-  const [process, setProcess] = React.useState<boolean>(false);
-
-  const { controller, switchDialog } = useDialog({
-    "leave-confirm": (
-      <ConfirmDialog
-        onConfirm={onClose}
-        title="timeline.dropDraft"
-        body="timeline.confirmLeave"
-      />
-    ),
-  });
-
-  const [text, _setText] = React.useState<string>("");
-  const [images, _setImages] = React.useState<{ file: File; url: string }[]>(
-    [],
-  );
-  const [previewHtml, _setPreviewHtml] = React.useState<string>("");
-
-  const _builder = React.useRef<TimelinePostBuilder | null>(null);
-
-  const getBuilder = (): TimelinePostBuilder => {
-    if (_builder.current == null) {
-      const builder = new TimelinePostBuilder(() => {
-        setCanLeave(builder.isEmpty);
-        _setText(builder.text);
-        _setImages(builder.images);
-        _setPreviewHtml(builder.renderHtml());
-      });
-      _builder.current = builder;
-    }
-    return _builder.current;
+function generateMarkedOptions(imageUrls: string[]): marked.MarkedOptions {
+  return {
+    mangle: false,
+    headerIds: false,
+    renderer: new MarkedRenderer(imageUrls),
   };
+}
 
-  const canSend = text.length > 0;
+function renderHtml(text: string, imageUrls: string[]): string {
+  return marked.parse(text, generateMarkedOptions(imageUrls));
+}
 
-  React.useEffect(() => {
-    return () => {
-      getBuilder().dispose();
-    };
-  }, []);
+async function build(
+  text: string,
+  images: File[],
+): Promise<HttpTimelinePostPostRequestData[]> {
+  return [
+    {
+      contentType: "text/markdown",
+      data: await base64(text),
+    },
+    ...(await Promise.all(
+      images.map(async (image) => {
+        const data = await base64(image);
+        return { contentType: image.type, data };
+      }),
+    )),
+  ];
+}
 
-  React.useEffect(() => {
-    window.onbeforeunload = (): unknown => {
-      if (!canLeave) {
-        return t("timeline.confirmLeave");
-      }
-    };
+export function useMarkdownEdit(disabled: boolean): {
+  hasContent: boolean;
+  clear: () => void;
+  build: () => Promise<HttpTimelinePostPostRequestData[]>;
+  markdownEditProps: Omit<MarkdownPostEditProps, "className">;
+} {
+  const [text, setText] = useState<string>("");
+  const [images, setImages] = useState<File[]>([]);
+
+  return {
+    hasContent: text !== "" || images.length !== 0,
+    clear: () => {
+      setText("");
+      setImages([]);
+    },
+    build: () => {
+      return build(text, images);
+    },
+    markdownEditProps: {
+      disabled,
+      text,
+      images,
+      onTextChange: setText,
+      onImageAppend: (image) => setImages(array.copy_push(images, image)),
+      onImageMove: (o, n) => setImages(array.copy_move(images, o, n)),
+      onImageDelete: (i) => setImages(array.copy_delete(images, i)),
+    },
+  };
+}
+
+function MarkdownPreview({ text, images }: { text: string; images: File[] }) {
+  const [html, setHtml] = useState("");
+
+  useEffect(() => {
+    const imageUrls = images.map((image) => URL.createObjectURL(image));
+
+    setHtml(renderHtml(text, imageUrls));
 
     return () => {
-      window.onbeforeunload = null;
+      imageUrls.forEach((url) => URL.revokeObjectURL(url));
     };
-  }, [canLeave, t]);
-
-  const send = async (): Promise<void> => {
-    setProcess(true);
-    try {
-      const dataList = await getBuilder().build();
-      const post = await getHttpTimelineClient().postPost(
-        ownerUsername,
-        timelineName,
-        {
-          dataList,
-        },
-      );
-      onPosted(post);
-      onClose();
-    } catch (e) {
-      setProcess(false);
-      onPostError();
-    }
-  };
+  }, [text, images]);
 
   return (
-    <>
-      <TabPages
-        className={className}
-        dense
-        actions={
-          process ? (
-            <Spinner />
-          ) : (
-            <div>
-              <IconButton
-                icon="x"
-                color="danger"
-                large
-                className="cru-align-middle me-2"
-                onClick={() => {
-                  if (canLeave) {
-                    onClose();
-                  } else {
-                    switchDialog("leave-confirm");
+    <div
+      className="timeline-edit-markdown-preview"
+      dangerouslySetInnerHTML={{ __html: html }}
+    />
+  );
+}
+
+interface MarkdownPostEditProps {
+  disabled: boolean;
+  text: string;
+  images: File[];
+  onTextChange: (text: string) => void;
+  onImageAppend: (image: File) => void;
+  onImageMove: (oldIndex: number, newIndex: number) => void;
+  onImageDelete: (index: number) => void;
+  className?: string;
+}
+
+export function MarkdownPostEdit({
+  disabled,
+  text,
+  images,
+  onTextChange,
+  onImageAppend,
+  // onImageMove,
+  onImageDelete,
+  className,
+}: MarkdownPostEditProps) {
+  return (
+    <TabPages
+      className={className}
+      dense
+      pages={[
+        {
+          name: "text",
+          text: "edit",
+          page: (
+            <textarea
+              value={text}
+              disabled={disabled}
+              className="timeline-edit-markdown-text"
+              onChange={(event) => {
+                onTextChange(event.currentTarget.value);
+              }}
+            />
+          ),
+        },
+        {
+          name: "images",
+          text: "image",
+          page: (
+            <div className="timeline-edit-markdown-images">
+              {images.map((image, index) => (
+                <div
+                  key={image.name}
+                  className="timeline-edit-markdown-image-container"
+                >
+                  <BlobImage src={image} />
+                  <IconButton
+                    icon="trash"
+                    color="danger"
+                    className={classnames(
+                      "timeline-edit-markdown-image-delete",
+                      process && "d-none",
+                    )}
+                    onClick={() => {
+                      onImageDelete(index);
+                    }}
+                  />
+                </div>
+              ))}
+              <input
+                type="file"
+                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) {
+                    onImageAppend(files[0]);
                   }
                 }}
+                disabled={disabled}
               />
-              {canSend && (
-                <FlatButton text="timeline.send" onClick={() => void send()} />
-              )}
             </div>
-          )
-        }
-        pages={[
-          {
-            name: "text",
-            text: "edit",
-            page: (
-              <textarea
-                value={text}
-                disabled={process}
-                className="timeline-post-create-markdown-edit-area cru-fill-parent"
-                onChange={(event) => {
-                  getBuilder().setMarkdownText(event.currentTarget.value);
-                }}
-              />
-            ),
-          },
-          {
-            name: "images",
-            text: "image",
-            page: (
-              <div className="timeline-markdown-post-edit-page">
-                {images.map((image, index) => (
-                  <div
-                    key={image.url}
-                    className="timeline-markdown-post-edit-image-container"
-                  >
-                    <img
-                      src={image.url}
-                      className="timeline-markdown-post-edit-image"
-                    />
-                    <IconButton
-                      icon="trash"
-                      color="danger"
-                      className={classnames(
-                        "timeline-markdown-post-edit-image-delete-button",
-                        process && "d-none",
-                      )}
-                      onClick={() => {
-                        getBuilder().deleteImage(index);
-                      }}
-                    />
-                  </div>
-                ))}
-                <input
-                  type="file"
-                  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) {
-                      getBuilder().appendImage(files[0]);
-                    }
-                  }}
-                  disabled={process}
-                />
-              </div>
-            ),
-          },
-          {
-            name: "preview",
-            text: "preview",
-            page: (
-              <div
-                className="markdown-container timeline-markdown-post-edit-page"
-                dangerouslySetInnerHTML={{ __html: previewHtml }}
-              />
-            ),
-          },
-        ]}
-      />
-      <DialogProvider controller={controller} />
-    </>
+          ),
+        },
+        {
+          name: "preview",
+          text: "preview",
+          page: <MarkdownPreview text={text} images={images} />,
+        },
+      ]}
+    />
   );
-};
-
-export default MarkdownPostEdit;
+}
diff --git a/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.css b/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.css
index 0f2b9dbd..4a608304 100644
--- a/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.css
+++ b/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.css
@@ -1,4 +1,4 @@
-.timeline-post-create-edit-text {
+.timeline-edit-plain-text-input {
   width: 100%;
   height: 100%;
   background-color: var(--cru-background-color);
@@ -6,13 +6,14 @@
   border: 1px solid var(--cru-text-major-color);
   padding: 0.5em;
   border-radius: 5px;
+  transition: border-color 0.5s;
 }
 
-.timeline-post-create-edit-text:hover {
+.timeline-edit-plain-text-input:hover {
   border-color: var(--cru-clickable-secondary-normal-color);
 }
 
-.timeline-post-create-edit-text:focus {
+.timeline-edit-plain-text-input:focus {
   border-color: var(--cru-clickable-secondary-normal-color);
 }
 
diff --git a/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.tsx b/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.tsx
index 1bea3daf..7f3663b2 100644
--- a/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.tsx
+++ b/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.tsx
@@ -1,4 +1,6 @@
-import classNames from 'classnames'
+import classNames from "classnames";
+
+import "./PlainTextPostEdit.css";
 
 interface TimelinePostEditTextProps {
   text: string;
@@ -11,16 +13,17 @@ export default function TimelinePostEditText(props: TimelinePostEditTextProps) {
   const { text, disabled, onChange, className } = props;
 
   return (
-    <div className={classNames("timeline-post-create-edit-container", className)}>
+    <div
+      className={classNames("timeline-edit-plain-text-container", className)}
+    >
       <textarea
         value={text}
         disabled={disabled}
         onChange={(event) => {
           onChange(event.target.value);
         }}
-        className={classNames("timeline-post-create-edit-text")}
+        className={classNames("timeline-edit-plain-text-input")}
       />
     </div>
   );
 }
-
diff --git a/FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.tsx b/FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.tsx
index 45d742f7..0de75ccd 100644
--- a/FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.tsx
+++ b/FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.tsx
@@ -1,5 +1,4 @@
-import { useState, useEffect, ChangeEventHandler } from "react";
-import { useTranslation } from "react-i18next";
+import { useState } from "react";
 import classNames from "classnames";
 
 import { UiLogicError } from "~src/common";
@@ -13,22 +12,20 @@ import {
 
 import base64 from "~src/utilities/base64";
 
+import { useC } from "~/src/components/common";
 import { pushAlert } from "~src/components/alert";
-import LoadingButton from "~src/components/button/LoadingButton";
+import { IconButton, LoadingButton } from "~src/components/button";
 import PopupMenu from "~src/components/menu/PopupMenu";
+import { useWindowLeave } from "~src/components/hooks";
+
 import TimelinePostCard from "../TimelinePostCard";
 import TimelinePostContainer from "../TimelinePostContainer";
-import IconButton from "~src/components/button/IconButton";
-
-import PlainTextPostEdit from './PlainTextPostEdit'
-import MarkdownPostEdit from "./MarkdownPostEdit";
+import PlainTextPostEdit from "./PlainTextPostEdit";
+import ImagePostEdit from "./ImagePostEdit";
+import { MarkdownPostEdit, useMarkdownEdit } from "./MarkdownPostEdit";
 
 import "./TimelinePostCreateView.css";
 
-
-
-
-
 type PostKind = "text" | "markdown" | "image";
 
 const postKindIconMap: Record<PostKind, string> = {
@@ -46,25 +43,30 @@ export interface TimelinePostEditProps {
 function TimelinePostEdit(props: TimelinePostEditProps) {
   const { timeline, className, onPosted } = props;
 
-  const { t } = useTranslation();
+  const c = useC();
 
   const [process, setProcess] = useState<boolean>(false);
 
-  const [kind, setKind] = useState<Exclude<PostKind, "markdown">>("text");
-  const [showMarkdown, setShowMarkdown] = useState<boolean>(false);
-
-  const [text, setText] = useState<string>("");
-  const [image, setImage] = useState<File | null>(null);
+  const [kind, setKind] = useState<PostKind>("text");
 
   const draftTextLocalStorageKey = `timeline.${timeline.owner.username}.${timeline.nameV2}.postDraft.text`;
+  const [text, setText] = useState<string>(
+    () => window.localStorage.getItem(draftTextLocalStorageKey) ?? "",
+  );
+  const [image, setImage] = useState<File | null>(null);
+  const {
+    hasContent: mdHasContent,
+    build: mdBuild,
+    clear: mdClear,
+    markdownEditProps,
+  } = useMarkdownEdit(process);
 
-  useEffect(() => {
-    setText(window.localStorage.getItem(draftTextLocalStorageKey) ?? "");
-  }, [draftTextLocalStorageKey]);
+  useWindowLeave(!mdHasContent && !image);
 
   const canSend =
     (kind === "text" && text.length !== 0) ||
-    (kind === "image" && image != null);
+    (kind === "image" && image != null) ||
+    (kind === "markdown" && mdHasContent);
 
   const onPostError = (): void => {
     pushAlert({
@@ -76,48 +78,59 @@ function TimelinePostEdit(props: TimelinePostEditProps) {
   const onSend = async (): Promise<void> => {
     setProcess(true);
 
-    let requestData: HttpTimelinePostPostRequestData;
+    let requestDataList: HttpTimelinePostPostRequestData[];
     switch (kind) {
       case "text":
-        requestData = {
-          contentType: "text/plain",
-          data: await base64(text),
-        };
+        requestDataList = [
+          {
+            contentType: "text/plain",
+            data: await base64(text),
+          },
+        ];
         break;
       case "image":
         if (image == null) {
-          throw new UiLogicError(
-            "Content type is image but image blob is null.",
-          );
+          throw new UiLogicError();
         }
-        requestData = {
-          contentType: image.type,
-          data: await base64(image),
-        };
+        requestDataList = [
+          {
+            contentType: image.type,
+            data: await base64(image),
+          },
+        ];
         break;
+      case "markdown":
+        if (!mdHasContent) {
+          throw new UiLogicError();
+        }
+        requestDataList = await mdBuild();
       default:
         throw new UiLogicError("Unknown content type.");
     }
 
-    getHttpTimelineClient()
-      .postPost(timeline.owner.username, timeline.nameV2, {
-        dataList: [requestData],
-      })
-      .then(
-        (data) => {
-          if (kind === "text") {
-            setText("");
-            window.localStorage.removeItem(draftTextLocalStorageKey);
-          }
-          setProcess(false);
-          setKind("text");
-          onPosted(data);
-        },
-        () => {
-          setProcess(false);
-          onPostError();
+    try {
+      const res = await getHttpTimelineClient().postPost(
+        timeline.owner.username,
+        timeline.nameV2,
+        {
+          dataList: requestDataList,
         },
       );
+
+      if (kind === "text") {
+        setText("");
+        window.localStorage.removeItem(draftTextLocalStorageKey);
+      } else if (kind === "image") {
+        setImage(null);
+      } else if (kind === "markdown") {
+        mdClear();
+      }
+      onPosted(res);
+    } catch (e) {
+      onPostError();
+    } finally {
+      setProcess(false);
+    }
   };
 
   return (
@@ -125,73 +138,51 @@ function TimelinePostEdit(props: TimelinePostEditProps) {
       className={classNames(className, "timeline-post-create-container")}
     >
       <TimelinePostCard className="timeline-post-create-card">
-        {showMarkdown ? (
-          <MarkdownPostEdit
-            className="cru-fill-parent"
-            onClose={() => setShowMarkdown(false)}
-            owner={timeline.owner.username}
-            timeline={timeline.nameV2}
-            onPosted={onPosted}
-            onPostError={onPostError}
-          />
-        ) : (
-          <div className="timeline-post-create">
-            <div className="timeline-post-create-edit-area">
-              {(() => {
-                if (kind === "text") {
-                  return (
-                    <PlainTextPostEdit
-                      className="timeline-post-create-edit-text"
-                      text={text}
-                      disabled={process}
-                      onChange={(text) => {
-                        setText(text);
-                        window.localStorage.setItem(
-                          draftTextLocalStorageKey,
-                          text,
-                        );
-                      }}
-                    />
-                  );
-                } else if (kind === "image") {
-                  return (
-                    <TimelinePostEditImage
-                      onSelect={setImage}
-                      disabled={process}
-                    />
-                  );
-                }
-              })()}
-            </div>
-            <div className="timeline-post-create-right-area">
-              <PopupMenu
-                containerClassName="timeline-post-create-kind-select"
-                items={(["text", "image", "markdown"] as const).map((kind) => ({
-                  type: "button",
-                  text: `timeline.post.type.${kind}`,
-                  iconClassName: postKindIconMap[kind],
-                  onClick: () => {
-                    if (kind === "markdown") {
-                      setShowMarkdown(true);
-                    } else {
-                      setKind(kind);
-                    }
-                  },
-                }))}
-              >
-                <IconButton color="primary" icon={postKindIconMap[kind]} />
-              </PopupMenu>
-              <LoadingButton
-                onClick={() => void onSend()}
-                color="primary"
-                disabled={!canSend}
-                loading={process}
-              >
-                {t("timeline.send")}
-              </LoadingButton>
-            </div>
+        <div className="timeline-post-create">
+          <div className="timeline-post-create-edit-area">
+            {kind === "text" && (
+              <PlainTextPostEdit
+                text={text}
+                disabled={process}
+                onChange={(text) => {
+                  setText(text);
+                  window.localStorage.setItem(draftTextLocalStorageKey, text);
+                }}
+              />
+            )}
+            {kind === "image" && (
+              <ImagePostEdit
+                file={image}
+                onChange={setImage}
+                disabled={process}
+              />
+            )}
+            {kind === "markdown" && <MarkdownPostEdit {...markdownEditProps} />}
+          </div>
+          <div className="timeline-post-create-right-area">
+            <PopupMenu
+              containerClassName="timeline-post-create-kind-select"
+              items={(["text", "image", "markdown"] as const).map((kind) => ({
+                type: "button",
+                text: `timeline.post.type.${kind}`,
+                iconClassName: postKindIconMap[kind],
+                onClick: () => {
+                  setKind(kind);
+                },
+              }))}
+            >
+              <IconButton color="primary" icon={postKindIconMap[kind]} />
+            </PopupMenu>
+            <LoadingButton
+              onClick={() => void onSend()}
+              color="primary"
+              disabled={!canSend}
+              loading={process}
+            >
+              {c("timeline.send")}
+            </LoadingButton>
           </div>
-        )}
+        </div>
       </TimelinePostCard>
     </TimelinePostContainer>
   );
diff --git a/FrontEnd/src/services/TimelinePostBuilder.ts b/FrontEnd/src/services/TimelinePostBuilder.ts
deleted file mode 100644
index 919d4f55..00000000
--- a/FrontEnd/src/services/TimelinePostBuilder.ts
+++ /dev/null
@@ -1,128 +0,0 @@
-import { marked } from "marked";
-
-import { UiLogicError } from "~src/common";
-
-import base64 from "~src/utilities/base64";
-
-import { HttpTimelinePostPostRequest } from "~src/http/timeline";
-
-class TimelinePostMarkedRenderer extends marked.Renderer {
-  constructor(private _images: { file: File; url: string }[]) {
-    super();
-  }
-
-  image(href: string | null, title: string | null, text: string): string {
-    if (href != null) {
-      const i = parseInt(href);
-      if (!isNaN(i) && i > 0 && i <= this._images.length) {
-        href = this._images[i - 1].url;
-      }
-    }
-    return this.image(href, title, text);
-  }
-}
-
-export default class TimelinePostBuilder {
-  private _onChange: () => void;
-  private _text = "";
-  private _images: { file: File; url: string }[] = [];
-  private _markedOptions: marked.MarkedOptions;
-
-  constructor(onChange: () => void) {
-    this._onChange = onChange;
-    this._markedOptions = {
-      renderer: new TimelinePostMarkedRenderer(this._images),
-    };
-  }
-
-  setMarkdownText(text: string): void {
-    this._text = text;
-    this._onChange();
-  }
-
-  appendImage(file: File): void {
-    this._images = this._images.slice();
-    this._images.push({
-      file,
-      url: URL.createObjectURL(file),
-    });
-    this._onChange();
-  }
-
-  moveImage(oldIndex: number, newIndex: number): void {
-    if (oldIndex < 0 || oldIndex >= this._images.length) {
-      throw new UiLogicError("Old index out of range.");
-    }
-
-    if (newIndex < 0) {
-      newIndex = 0;
-    }
-
-    if (newIndex >= this._images.length) {
-      newIndex = this._images.length - 1;
-    }
-
-    this._images = this._images.slice();
-
-    const [old] = this._images.splice(oldIndex, 1);
-    this._images.splice(newIndex, 0, old);
-
-    this._onChange();
-  }
-
-  deleteImage(index: number): void {
-    if (index < 0 || index >= this._images.length) {
-      throw new UiLogicError("Old index out of range.");
-    }
-
-    this._images = this._images.slice();
-
-    URL.revokeObjectURL(this._images[index].url);
-    this._images.splice(index, 1);
-
-    this._onChange();
-  }
-
-  get text(): string {
-    return this._text;
-  }
-
-  get images(): { file: File; url: string }[] {
-    return this._images;
-  }
-
-  get isEmpty(): boolean {
-    return this._text.length === 0 && this._images.length === 0;
-  }
-
-  renderHtml(): string {
-    return marked.parse(this._text, {
-      mangle: false,
-      headerIds: false,
-    });
-  }
-
-  dispose(): void {
-    for (const image of this._images) {
-      URL.revokeObjectURL(image.url);
-    }
-    this._images = [];
-  }
-
-  async build(): Promise<HttpTimelinePostPostRequest["dataList"]> {
-    return [
-      {
-        contentType: "text/markdown",
-        data: await base64(this._text),
-      },
-      ...(await Promise.all(
-        this._images.map((image) =>
-          base64(image.file).then((data) => ({
-            contentType: image.file.type,
-            data,
-          })),
-        ),
-      )),
-    ];
-  }
-}
diff --git a/FrontEnd/src/utilities/array.ts b/FrontEnd/src/utilities/array.ts
new file mode 100644
index 00000000..838e8744
--- /dev/null
+++ b/FrontEnd/src/utilities/array.ts
@@ -0,0 +1,41 @@
+export function copy_move<T>(
+  array: T[],
+  oldIndex: number,
+  newIndex: number,
+): T[] {
+  if (oldIndex < 0 || oldIndex >= array.length) {
+    throw new Error("Old index out of range.");
+  }
+
+  if (newIndex < 0) {
+    newIndex = 0;
+  }
+
+  if (newIndex >= array.length) {
+    newIndex = array.length - 1;
+  }
+
+  const result = array.slice();
+  const [element] = result.splice(oldIndex, 1);
+  result.splice(newIndex, 0, element);
+
+  return result;
+}
+
+export function copy_insert<T>(array: T[], index: number, element: T): T[] {
+  const result = array.slice();
+  result.splice(index, 0, element);
+  return result;
+}
+
+export function copy_push<T>(array: T[], element: T): T[] {
+  const result = array.slice();
+  result.push(element);
+  return result;
+}
+
+export function copy_delete<T>(array: T[], index: number): T[] {
+  const result = array.slice();
+  result.splice(index, 1);
+  return array;
+}
-- 
cgit v1.2.3


From aef14be13c3bd9e93eeea598dbfbf707ba98d448 Mon Sep 17 00:00:00 2001
From: crupest <crupest@outlook.com>
Date: Thu, 14 Sep 2023 23:47:16 +0800
Subject: ...

---
 FrontEnd/src/components/Page.css                   |  8 +++
 FrontEnd/src/components/Page.tsx                   |  2 +
 FrontEnd/src/components/index.css                  | 66 +++-------------------
 FrontEnd/src/components/menu/Menu.tsx              | 14 ++---
 FrontEnd/src/components/menu/PopupMenu.tsx         |  3 +-
 FrontEnd/src/components/tab/TabBar.css             | 18 ++----
 FrontEnd/src/components/tab/TabPages.css           |  3 +
 FrontEnd/src/components/theme.css                  |  4 +-
 FrontEnd/src/pages/timeline/edit/ImagePostEdit.css |  3 +-
 FrontEnd/src/pages/timeline/edit/ImagePostEdit.tsx | 10 ++--
 .../src/pages/timeline/edit/MarkdownPostEdit.css   | 34 ++++-------
 .../src/pages/timeline/edit/MarkdownPostEdit.tsx   |  3 +-
 .../src/pages/timeline/edit/PlainTextPostEdit.css  | 19 ++-----
 .../pages/timeline/edit/TimelinePostCreateView.css |  8 ++-
 .../pages/timeline/edit/TimelinePostCreateView.tsx |  2 +
 15 files changed, 71 insertions(+), 126 deletions(-)
 create mode 100644 FrontEnd/src/components/Page.css

(limited to 'FrontEnd/src/pages/timeline/edit/ImagePostEdit.tsx')

diff --git a/FrontEnd/src/components/Page.css b/FrontEnd/src/components/Page.css
new file mode 100644
index 00000000..b22d83af
--- /dev/null
+++ b/FrontEnd/src/components/Page.css
@@ -0,0 +1,8 @@
+.cru-page {
+  padding: var(--cru-page-padding);
+}
+
+.cru-page-no-top-padding {
+  padding-top: 0;
+}
+
diff --git a/FrontEnd/src/components/Page.tsx b/FrontEnd/src/components/Page.tsx
index 86fdb2f5..8c9febcc 100644
--- a/FrontEnd/src/components/Page.tsx
+++ b/FrontEnd/src/components/Page.tsx
@@ -1,6 +1,8 @@
 import { ComponentPropsWithoutRef, Ref } from "react";
 import classNames from "classnames";
 
+import "./Page.css";
+
 interface PageProps extends ComponentPropsWithoutRef<"div"> {
   noTopPadding?: boolean;
   pageRef?: Ref<HTMLDivElement>;
diff --git a/FrontEnd/src/components/index.css b/FrontEnd/src/components/index.css
index 32e762cb..de156e0e 100644
--- a/FrontEnd/src/components/index.css
+++ b/FrontEnd/src/components/index.css
@@ -13,69 +13,17 @@ body {
   line-height: 1.2;
 }
 
-.cru-page {
-  padding: var(--cru-page-padding);
+textarea {
+  transition: border-color 0.3s;
+  border-color: var(--cru-text-minor-color);
 }
 
-.cru-page-no-top-padding {
-  padding-top: 0;
+textarea:hover {
+  border-color: var(--cru-clickable-primary-hover-color);
 }
 
-.cru-text-center {
-  text-align: center;
-}
-
-.cru-text-end {
-  text-align: end;
-}
-
-.cru-float-left {
-  float: left;
-}
-
-.cru-float-right {
-  float: right;
-}
-
-.cru-align-text-bottom {
-  vertical-align: text-bottom;
-}
-
-.cru-align-middle {
-  vertical-align: middle;
-}
-
-.cru-clearfix::after {
-  clear: both;
-}
-
-.cru-fill-parent {
-  width: 100%;
-  height: 100%;
-}
-
-.cru-avatar {
-  width: 60px;
-  height: 60px;
-}
-
-.cru-avatar.large {
-  width: 100px;
-  height: 100px;
-}
-
-.cru-avatar.small {
-  width: 40px;
-  height: 40px;
-}
-
-.cru-round {
-  border-radius: 50%;
-}
-
-.cru-tab-pages-action-area {
-  display: flex;
-  align-items: center;
+textarea:focus {
+  border-color: var(--cru-clickable-primary-normal-color);
 }
 
 .alert-container {
diff --git a/FrontEnd/src/components/menu/Menu.tsx b/FrontEnd/src/components/menu/Menu.tsx
index c01c6cfb..1a196a69 100644
--- a/FrontEnd/src/components/menu/Menu.tsx
+++ b/FrontEnd/src/components/menu/Menu.tsx
@@ -1,4 +1,4 @@
-import { CSSProperties } from "react";
+import { MouseEvent, CSSProperties } from "react";
 import classNames from "classnames";
 
 import { useC, Text, ThemeColor } from "../common";
@@ -15,21 +15,21 @@ export type MenuItem =
       text: Text;
       icon?: string;
       color?: ThemeColor;
-      onClick: () => void;
+      onClick?: (e: MouseEvent<HTMLButtonElement>) => void;
     };
 
 export type MenuItems = MenuItem[];
 
 export type MenuProps = {
   items: MenuItems;
-  onItemClicked?: () => void;
+  onItemClick?: (e: MouseEvent<HTMLButtonElement>) => void;
   className?: string;
   style?: CSSProperties;
 };
 
 export default function Menu({
   items,
-  onItemClicked,
+  onItemClick,
   className,
   style,
 }: MenuProps) {
@@ -46,9 +46,9 @@ export default function Menu({
             <button
               key={index}
               className={`cru-menu-item cru-clickable-${color ?? "primary"}`}
-              onClick={() => {
-                onClick();
-                onItemClicked?.();
+              onClick={(e) => {
+                onClick?.(e);
+                onItemClick?.(e);
               }}
             >
               {icon != null && <Icon color={color} icon={icon} />}
diff --git a/FrontEnd/src/components/menu/PopupMenu.tsx b/FrontEnd/src/components/menu/PopupMenu.tsx
index 9d90799d..7ac2abfe 100644
--- a/FrontEnd/src/components/menu/PopupMenu.tsx
+++ b/FrontEnd/src/components/menu/PopupMenu.tsx
@@ -58,8 +58,9 @@ export default function PopupMenu({
           >
             <Menu
               items={items}
-              onItemClicked={() => {
+              onItemClick={(e) => {
                 setShow(false);
+                e.stopPropagation();
               }}
             />
           </div>,
diff --git a/FrontEnd/src/components/tab/TabBar.css b/FrontEnd/src/components/tab/TabBar.css
index 09d48c59..dc6970c7 100644
--- a/FrontEnd/src/components/tab/TabBar.css
+++ b/FrontEnd/src/components/tab/TabBar.css
@@ -1,30 +1,24 @@
 .cru-tab-bar {
-  border-bottom: var(--cru-clickable-normal-color) 1px solid;
   display: flex;
 }
 
 .cru-tab-bar-tab-area {
   display: flex;
   align-items: center;
-  gap: 0.5em;
-  border: var(--cru-clickable-normal-color) 1px solid;
-  border-bottom: none;
-  border-top-left-radius: 5px;
-  border-top-right-radius: 5px;
-  padding: 0.2em 0.5em;
+  border: var(--cru-clickable-normal-color) 1.6px solid;
+  border-radius: 3px;
+  overflow: hidden;
 }
 
 .cru-tab-bar-item {
-  color: var(--cru-clickable-normal-color);
-  transition: all 0.5s;
-  border-radius: 5px;
+  color: var(--cru-text-minor-color);
+  transition: all 0.2s;
   cursor: pointer;
   padding: 0.3em 1em;
 }
 
 .cru-tab-bar-item:hover {
-  color: var(--cru-push-button-text-color);
-  background-color: var(--cru-clickable-normal-color);
+  color: var(--cru-clickable-normal-color);
 }
 
 .cru-tab-bar-item.active {
diff --git a/FrontEnd/src/components/tab/TabPages.css b/FrontEnd/src/components/tab/TabPages.css
index e69de29b..c07d042e 100644
--- a/FrontEnd/src/components/tab/TabPages.css
+++ b/FrontEnd/src/components/tab/TabPages.css
@@ -0,0 +1,3 @@
+.cru-tab-page-container {
+  padding-top: 0.5em;
+}
diff --git a/FrontEnd/src/components/theme.css b/FrontEnd/src/components/theme.css
index 487ab959..4727d6dd 100644
--- a/FrontEnd/src/components/theme.css
+++ b/FrontEnd/src/components/theme.css
@@ -1,5 +1,5 @@
 :root {
-  --cru-default-font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
+  --cru-default-font-family: 'Segoe UI', 'DengXian', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
   --cru-page-padding: 1em 2em;
 
   --cru-border-radius: 4px;
@@ -197,4 +197,4 @@
 .cru-card-danger {
   --cru-card-background-color: var(--cru-card-background-danger-color);
   --cru-card-border-color: var(--cru-card-border-danger-color)
-}
\ No newline at end of file
+}
diff --git a/FrontEnd/src/pages/timeline/edit/ImagePostEdit.css b/FrontEnd/src/pages/timeline/edit/ImagePostEdit.css
index df7a6af6..232681c8 100644
--- a/FrontEnd/src/pages/timeline/edit/ImagePostEdit.css
+++ b/FrontEnd/src/pages/timeline/edit/ImagePostEdit.css
@@ -1,4 +1,5 @@
-.timeline-edit-image {
+.timeline-edit-image-image {
   max-width: 100px;
   max-height: 100px;
 }
+
diff --git a/FrontEnd/src/pages/timeline/edit/ImagePostEdit.tsx b/FrontEnd/src/pages/timeline/edit/ImagePostEdit.tsx
index 4676e45a..c62c8ee5 100644
--- a/FrontEnd/src/pages/timeline/edit/ImagePostEdit.tsx
+++ b/FrontEnd/src/pages/timeline/edit/ImagePostEdit.tsx
@@ -2,6 +2,8 @@ import classNames from "classnames";
 
 import BlobImage from "~/src/components/BlobImage";
 
+import "./ImagePostEdit.css";
+
 interface TimelinePostEditImageProps {
   file: File | null;
   onChange: (file: File | null) => void;
@@ -13,9 +15,7 @@ export default function ImagePostEdit(props: TimelinePostEditImageProps) {
   const { file, onChange, disabled, className } = props;
 
   return (
-    <div
-      className={classNames("timeline-edit-image-container", className)}
-    >
+    <div className={classNames("timeline-edit-image-container", className)}>
       <input
         type="file"
         accept="image/*"
@@ -28,9 +28,9 @@ export default function ImagePostEdit(props: TimelinePostEditImageProps) {
             onChange(files[0]);
           }
         }}
-        className="mx-3 my-1"
+        className="timeline-edit-image-input"
       />
-      {file && <BlobImage src={file} className="timeline-edit-image" />}
+      {file && <BlobImage src={file} className="timeline-edit-image-image" />}
     </div>
   );
 }
diff --git a/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.css b/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.css
index 33a77943..c5b41b40 100644
--- a/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.css
+++ b/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.css
@@ -1,34 +1,24 @@
-.timeline-markdown-post-edit-page {
-  overflow: auto;
-  max-height: 300px;
+.timeline-edit-markdown-tab-page {
+  min-height: 8em;
+  display: flex;
 }
 
-.timeline-post-create-markdown-edit-area {
-  border: 1px solid var(--cru-clickable-primary-normal-color);
-  border-top: none;
-  border-bottom-left-radius: 5px;
-  border-bottom-right-radius: 5px;
-  padding: 0.6em;
+.timeline-edit-markdown-text {
+  width: 100%;
 }
 
-.timeline-post-create-markdown-edit-area:hover {
-  border: 1px solid var(--cru-clickable-primary-normal-color);
-  border-top: none;
+.timeline-edit-markdown-images {
+  display: flex;
+  flex-wrap: wrap;
 }
 
-.timeline-markdown-post-edit-image-container {
-  position: relative;
-  text-align: center;
-  margin-bottom: 1em;
+.timeline-edit-markdown-images img {
+  max-width: 100%;
+  max-height: 200px;
 }
 
-.timeline-markdown-post-edit-image {
+.timeline-edit-markdown-preview img {
   max-width: 100%;
   max-height: 200px;
 }
 
-.timeline-markdown-post-edit-image-delete-button {
-  position: absolute;
-  right: 10px;
-  top: 2px;
-}
diff --git a/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.tsx b/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.tsx
index 0dfaf33e..692221fd 100644
--- a/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.tsx
+++ b/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.tsx
@@ -27,7 +27,7 @@ class MarkedRenderer extends marked.Renderer {
       }
     }
 
-    return this.image(href, title, text);
+    return super.image(href, title, text);
   }
 }
 
@@ -136,6 +136,7 @@ export function MarkdownPostEdit({
   return (
     <TabPages
       className={className}
+      pageContainerClassName="timeline-edit-markdown-tab-page"
       dense
       pages={[
         {
diff --git a/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.css b/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.css
index 4a608304..d1a61793 100644
--- a/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.css
+++ b/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.css
@@ -1,19 +1,12 @@
-.timeline-edit-plain-text-input {
+.timeline-edit-plain-text-container {
   width: 100%;
   height: 100%;
-  background-color: var(--cru-background-color);
-  color: var(--cru-text-major-color);
-  border: 1px solid var(--cru-text-major-color);
-  padding: 0.5em;
-  border-radius: 5px;
-  transition: border-color 0.5s;
-}
-
-.timeline-edit-plain-text-input:hover {
-  border-color: var(--cru-clickable-secondary-normal-color);
 }
 
-.timeline-edit-plain-text-input:focus {
-  border-color: var(--cru-clickable-secondary-normal-color);
+.timeline-edit-plain-text-input {
+  width: 100%;
+  height: 100%;
+  padding: 0.5em;
+  border-radius: 4px;
 }
 
diff --git a/FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.css b/FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.css
index 5e93d9f2..6efe93e9 100644
--- a/FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.css
+++ b/FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.css
@@ -10,7 +10,7 @@
     padding-top: 60px;
   }
 
-  .timeline-post-create-card{
+  .timeline-post-create-card {
     margin-right: 0;
   }
 }
@@ -23,11 +23,13 @@
   flex-grow: 1;
 }
 
-
-
 .timeline-post-create-right-area {
   display: flex;
   flex-direction: column;
   align-items: center;
   margin-left: 1em;
 }
+
+.timeline-post-create-send {
+  margin-top: auto;
+}
diff --git a/FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.tsx b/FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.tsx
index 0de75ccd..c0a80ad0 100644
--- a/FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.tsx
+++ b/FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.tsx
@@ -104,6 +104,7 @@ function TimelinePostEdit(props: TimelinePostEditProps) {
           throw new UiLogicError();
         }
         requestDataList = await mdBuild();
+        break;
       default:
         throw new UiLogicError("Unknown content type.");
     }
@@ -174,6 +175,7 @@ function TimelinePostEdit(props: TimelinePostEditProps) {
               <IconButton color="primary" icon={postKindIconMap[kind]} />
             </PopupMenu>
             <LoadingButton
+              className="timeline-post-create-send"
               onClick={() => void onSend()}
               color="primary"
               disabled={!canSend}
-- 
cgit v1.2.3

