From 538d6830a0022b49b99695095d85e567b0c86e71 Mon Sep 17 00:00:00 2001
From: crupest <crupest@outlook.com>
Date: Sun, 30 Jul 2023 23:47:53 +0800
Subject: ...

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

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

diff --git a/FrontEnd/src/pages/timeline/ConnectionStatusBadge.tsx b/FrontEnd/src/pages/timeline/ConnectionStatusBadge.tsx
new file mode 100644
index 00000000..2b820454
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/ConnectionStatusBadge.tsx
@@ -0,0 +1,41 @@
+import * as React from "react";
+import classnames from "classnames";
+import { HubConnectionState } from "@microsoft/signalr";
+import { useTranslation } from "react-i18next";
+
+import "./ConnectionStatusBadge.css";
+
+export interface ConnectionStatusBadgeProps {
+  status: HubConnectionState;
+  className?: string;
+  style?: React.CSSProperties;
+}
+
+const classNameMap: Record<HubConnectionState, string> = {
+  Connected: "success",
+  Connecting: "warning",
+  Disconnected: "danger",
+  Disconnecting: "warning",
+  Reconnecting: "warning",
+};
+
+const ConnectionStatusBadge: React.FC<ConnectionStatusBadgeProps> = (props) => {
+  const { status, className, style } = props;
+
+  const { t } = useTranslation();
+
+  return (
+    <div
+      className={classnames(
+        "connection-status-badge",
+        classNameMap[status],
+        className
+      )}
+      style={style}
+    >
+      {t(`connectionState.${status}`)}
+    </div>
+  );
+};
+
+export default ConnectionStatusBadge;
-- 
cgit v1.2.3


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

---
 .../src/pages/timeline/ConnectionStatusBadge.css   |   2 +-
 .../src/pages/timeline/ConnectionStatusBadge.tsx   |  21 +-
 FrontEnd/src/pages/timeline/MarkdownPostEdit.css   |  34 ---
 FrontEnd/src/pages/timeline/MarkdownPostEdit.tsx   | 216 ----------------
 FrontEnd/src/pages/timeline/Timeline.tsx           |   2 +-
 .../src/pages/timeline/TimelinePostCreateView.css  |  54 ----
 .../src/pages/timeline/TimelinePostCreateView.tsx  | 272 ---------------------
 .../timeline/TimelinePropertyChangeDialog.tsx      |  16 +-
 FrontEnd/src/pages/timeline/edit/ImagePostEdit.css |   4 +
 FrontEnd/src/pages/timeline/edit/ImagePostEdit.tsx |  36 +++
 .../src/pages/timeline/edit/MarkdownPostEdit.css   |  34 +++
 .../src/pages/timeline/edit/MarkdownPostEdit.tsx   | 216 ++++++++++++++++
 .../src/pages/timeline/edit/PlainTextPostEdit.css  |  18 ++
 .../src/pages/timeline/edit/PlainTextPostEdit.tsx  |  26 ++
 .../pages/timeline/edit/TimelinePostCreateView.css |  33 +++
 .../pages/timeline/edit/TimelinePostCreateView.tsx | 200 +++++++++++++++
 16 files changed, 583 insertions(+), 601 deletions(-)
 delete mode 100644 FrontEnd/src/pages/timeline/MarkdownPostEdit.css
 delete mode 100644 FrontEnd/src/pages/timeline/MarkdownPostEdit.tsx
 delete mode 100644 FrontEnd/src/pages/timeline/TimelinePostCreateView.css
 delete mode 100644 FrontEnd/src/pages/timeline/TimelinePostCreateView.tsx
 create mode 100644 FrontEnd/src/pages/timeline/edit/ImagePostEdit.css
 create mode 100644 FrontEnd/src/pages/timeline/edit/ImagePostEdit.tsx
 create mode 100644 FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.css
 create mode 100644 FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.tsx
 create mode 100644 FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.css
 create mode 100644 FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.tsx
 create mode 100644 FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.css
 create mode 100644 FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.tsx

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

diff --git a/FrontEnd/src/pages/timeline/ConnectionStatusBadge.css b/FrontEnd/src/pages/timeline/ConnectionStatusBadge.css
index fc01484e..f2f8df66 100644
--- a/FrontEnd/src/pages/timeline/ConnectionStatusBadge.css
+++ b/FrontEnd/src/pages/timeline/ConnectionStatusBadge.css
@@ -22,7 +22,7 @@
 }
 
 .connection-status-badge.warning {
-  color: #e4a700;
+  color: #e4a700; /* TODO: Warning color */
 }
 
 .connection-status-badge.warning::before {
diff --git a/FrontEnd/src/pages/timeline/ConnectionStatusBadge.tsx b/FrontEnd/src/pages/timeline/ConnectionStatusBadge.tsx
index 2b820454..63990878 100644
--- a/FrontEnd/src/pages/timeline/ConnectionStatusBadge.tsx
+++ b/FrontEnd/src/pages/timeline/ConnectionStatusBadge.tsx
@@ -1,14 +1,13 @@
-import * as React from "react";
-import classnames from "classnames";
+import classNames from "classnames";
 import { HubConnectionState } from "@microsoft/signalr";
-import { useTranslation } from "react-i18next";
+
+import { useC }from '~/src/components/common';
 
 import "./ConnectionStatusBadge.css";
 
-export interface ConnectionStatusBadgeProps {
+interface ConnectionStatusBadgeProps {
   status: HubConnectionState;
   className?: string;
-  style?: React.CSSProperties;
 }
 
 const classNameMap: Record<HubConnectionState, string> = {
@@ -19,23 +18,19 @@ const classNameMap: Record<HubConnectionState, string> = {
   Reconnecting: "warning",
 };
 
-const ConnectionStatusBadge: React.FC<ConnectionStatusBadgeProps> = (props) => {
-  const { status, className, style } = props;
-
-  const { t } = useTranslation();
+export default function ConnectionStatusBadge({status, className}: ConnectionStatusBadgeProps) {
+  const c = useC();
 
   return (
     <div
-      className={classnames(
+      className={classNames(
         "connection-status-badge",
         classNameMap[status],
         className
       )}
-      style={style}
     >
-      {t(`connectionState.${status}`)}
+      {c(`connectionState.${status}`)}
     </div>
   );
 };
 
-export default ConnectionStatusBadge;
diff --git a/FrontEnd/src/pages/timeline/MarkdownPostEdit.css b/FrontEnd/src/pages/timeline/MarkdownPostEdit.css
deleted file mode 100644
index 33a77943..00000000
--- a/FrontEnd/src/pages/timeline/MarkdownPostEdit.css
+++ /dev/null
@@ -1,34 +0,0 @@
-.timeline-markdown-post-edit-page {
-  overflow: auto;
-  max-height: 300px;
-}
-
-.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-post-create-markdown-edit-area:hover {
-  border: 1px solid var(--cru-clickable-primary-normal-color);
-  border-top: none;
-}
-
-.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;
-}
diff --git a/FrontEnd/src/pages/timeline/MarkdownPostEdit.tsx b/FrontEnd/src/pages/timeline/MarkdownPostEdit.tsx
deleted file mode 100644
index d10d3f2d..00000000
--- a/FrontEnd/src/pages/timeline/MarkdownPostEdit.tsx
+++ /dev/null
@@ -1,216 +0,0 @@
-import * as React from "react";
-import classnames from "classnames";
-import { useTranslation } from "react-i18next";
-
-import {
-  getHttpTimelineClient,
-  HttpTimelinePostInfo,
-} from "~src/http/timeline";
-
-import TimelinePostBuilder from "~src/services/TimelinePostBuilder";
-
-import FlatButton from "~src/components/button/FlatButton";
-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 "./MarkdownPostEdit.css";
-
-export interface MarkdownPostEditProps {
-  owner: string;
-  timeline: string;
-  onPosted: (post: HttpTimelinePostInfo) => void;
-  onPostError: () => void;
-  onClose: () => void;
-  className?: string;
-}
-
-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;
-  };
-
-  const canSend = text.length > 0;
-
-  React.useEffect(() => {
-    return () => {
-      getBuilder().dispose();
-    };
-  }, []);
-
-  React.useEffect(() => {
-    window.onbeforeunload = (): unknown => {
-      if (!canLeave) {
-        return t("timeline.confirmLeave");
-      }
-    };
-
-    return () => {
-      window.onbeforeunload = null;
-    };
-  }, [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();
-    }
-  };
-
-  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");
-                  }
-                }}
-              />
-              {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} />
-    </>
-  );
-};
-
-export default MarkdownPostEdit;
diff --git a/FrontEnd/src/pages/timeline/Timeline.tsx b/FrontEnd/src/pages/timeline/Timeline.tsx
index 1d8e28d5..32cbf8c8 100644
--- a/FrontEnd/src/pages/timeline/Timeline.tsx
+++ b/FrontEnd/src/pages/timeline/Timeline.tsx
@@ -18,8 +18,8 @@ import { getTimelinePostUpdate$ } from "~src/services/timeline";
 import { useScrollToBottom } from "~src/components/hooks";
 
 import TimelinePostList from "./TimelinePostList";
-import TimelinePostEdit from "./TimelinePostCreateView";
 import TimelineInfoCard from "./TimelineInfoCard";
+import TimelinePostEdit from "./edit/TimelinePostCreateView";
 
 import "./Timeline.css";
 
diff --git a/FrontEnd/src/pages/timeline/TimelinePostCreateView.css b/FrontEnd/src/pages/timeline/TimelinePostCreateView.css
deleted file mode 100644
index e483ca17..00000000
--- a/FrontEnd/src/pages/timeline/TimelinePostCreateView.css
+++ /dev/null
@@ -1,54 +0,0 @@
-.timeline-post-create-card {
-  position: sticky !important;
-  top: 106px;
-  z-index: 100;
-  margin-right: 200px;
-}
-
-@media (max-width: 576px) {
-  .timeline-post-create-container {
-    padding-top: 60px;
-  }
-
-  .timeline-post-create-card{
-    margin-right: 0;
-  }
-}
-
-.timeline-post-create {
-  display: flex;
-}
-
-.timeline-post-create-edit-area {
-  flex-grow: 1;
-}
-
-.timeline-post-create-edit-text {
-  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;
-}
-
-.timeline-post-create-edit-text:hover {
-  border-color: var(--cru-clickable-secondary-normal-color)
-}
-
-.timeline-post-create-edit-text:focus {
-  border-color: var(--cru-clickable-secondary-normal-color)
-}
-
-.timeline-post-create-image {
-  max-width: 100px;
-  max-height: 100px;
-}
-
-.timeline-post-create-right-area {
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  margin-left: 1em;
-}
diff --git a/FrontEnd/src/pages/timeline/TimelinePostCreateView.tsx b/FrontEnd/src/pages/timeline/TimelinePostCreateView.tsx
deleted file mode 100644
index 70925cd9..00000000
--- a/FrontEnd/src/pages/timeline/TimelinePostCreateView.tsx
+++ /dev/null
@@ -1,272 +0,0 @@
-import { useState, useEffect, ChangeEventHandler } from "react";
-import { useTranslation } from "react-i18next";
-
-import { UiLogicError } from "~src/common";
-
-import {
-  getHttpTimelineClient,
-  HttpTimelineInfo,
-  HttpTimelinePostInfo,
-  HttpTimelinePostPostRequestData,
-} from "~src/http/timeline";
-
-import base64 from "~src/utilities/base64";
-
-import { pushAlert } from "~src/components/alert";
-import BlobImage from "~src/components/BlobImage";
-import LoadingButton from "~src/components/button/LoadingButton";
-import PopupMenu from "~src/components/menu/PopupMenu";
-import MarkdownPostEdit from "./MarkdownPostEdit";
-import TimelinePostCard from "./TimelinePostCard";
-import TimelinePostContainer from "./TimelinePostContainer";
-import IconButton from "~src/components/button/IconButton";
-
-import "./TimelinePostCreateView.css";
-import classNames from "classnames";
-
-interface TimelinePostEditTextProps {
-  text: string;
-  disabled: boolean;
-  onChange: (text: string) => void;
-  className?: string;
-}
-
-function TimelinePostEditText(props: TimelinePostEditTextProps) {
-  const { text, disabled, onChange, className } = props;
-
-  return (
-    <textarea
-      value={text}
-      disabled={disabled}
-      onChange={(event) => {
-        onChange(event.target.value);
-      }}
-      className={classNames("timeline-post-create-edit-text", className)}
-    />
-  );
-}
-
-interface TimelinePostEditImageProps {
-  onSelect: (file: File | null) => void;
-  disabled: boolean;
-}
-
-function TimelinePostEditImage(props: TimelinePostEditImageProps) {
-  const { onSelect, disabled } = props;
-
-  const { t } = useTranslation();
-
-  const [file, setFile] = useState<File | null>(null);
-  const [error, setError] = useState<boolean>(false);
-
-  const onInputChange: ChangeEventHandler<HTMLInputElement> = (e) => {
-    setError(false);
-    const files = e.target.files;
-    if (files == null || files.length === 0) {
-      setFile(null);
-      onSelect(null);
-    } else {
-      setFile(files[0]);
-    }
-  };
-
-  useEffect(() => {
-    return () => {
-      onSelect(null);
-    };
-  }, [onSelect]);
-
-  return (
-    <>
-      <input
-        type="file"
-        onChange={onInputChange}
-        accept="image/*"
-        disabled={disabled}
-        className="mx-3 my-1"
-      />
-      {file != null && !error && (
-        <BlobImage
-          src={file}
-          className="timeline-post-create-image"
-          onLoad={() => onSelect(file)}
-          onError={() => {
-            onSelect(null);
-            setError(true);
-          }}
-        />
-      )}
-      {error ? <div className="text-danger">{t("loadImageError")}</div> : null}
-    </>
-  );
-}
-
-type PostKind = "text" | "markdown" | "image";
-
-const postKindIconMap: Record<PostKind, string> = {
-  text: "fonts",
-  markdown: "markdown",
-  image: "image",
-};
-
-export interface TimelinePostEditProps {
-  className?: string;
-  timeline: HttpTimelineInfo;
-  onPosted: (newPost: HttpTimelinePostInfo) => void;
-}
-
-function TimelinePostEdit(props: TimelinePostEditProps) {
-  const { timeline, className, onPosted } = props;
-
-  const { t } = useTranslation();
-
-  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 draftTextLocalStorageKey = `timeline.${timeline.owner.username}.${timeline.nameV2}.postDraft.text`;
-
-  useEffect(() => {
-    setText(window.localStorage.getItem(draftTextLocalStorageKey) ?? "");
-  }, [draftTextLocalStorageKey]);
-
-  const canSend =
-    (kind === "text" && text.length !== 0) ||
-    (kind === "image" && image != null);
-
-  const onPostError = (): void => {
-    pushAlert({
-      color: "danger",
-      message: "timeline.sendPostFailed",
-    });
-  };
-
-  const onSend = async (): Promise<void> => {
-    setProcess(true);
-
-    let requestData: HttpTimelinePostPostRequestData;
-    switch (kind) {
-      case "text":
-        requestData = {
-          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.",
-          );
-        }
-        requestData = {
-          contentType: image.type,
-          data: await base64(image),
-        };
-        break;
-      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();
-        },
-      );
-  };
-
-  return (
-    <TimelinePostContainer
-      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 (
-                    <TimelinePostEditText
-                      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>
-        )}
-      </TimelinePostCard>
-    </TimelinePostContainer>
-  );
-}
-
-export default TimelinePostEdit;
diff --git a/FrontEnd/src/pages/timeline/TimelinePropertyChangeDialog.tsx b/FrontEnd/src/pages/timeline/TimelinePropertyChangeDialog.tsx
index ee5388cb..79838d58 100644
--- a/FrontEnd/src/pages/timeline/TimelinePropertyChangeDialog.tsx
+++ b/FrontEnd/src/pages/timeline/TimelinePropertyChangeDialog.tsx
@@ -1,5 +1,3 @@
-import * as React from "react";
-
 import {
   getHttpTimelineClient,
   HttpTimelineInfo,
@@ -10,7 +8,7 @@ import {
 
 import OperationDialog from "~src/components/dialog/OperationDialog";
 
-export interface TimelinePropertyChangeDialogProps {
+interface TimelinePropertyChangeDialogProps {
   timeline: HttpTimelineInfo;
   onChange: () => void;
 }
@@ -21,11 +19,10 @@ const labelMap: { [key in TimelineVisibility]: string } = {
   Register: "timeline.visibility.register",
 };
 
-const TimelinePropertyChangeDialog: React.FC<
-  TimelinePropertyChangeDialogProps
-> = (props) => {
-  const { timeline, onChange } = props;
-
+export default function TimelinePropertyChangeDialog({
+  timeline,
+  onChange,
+}: TimelinePropertyChangeDialogProps) {
   return (
     <OperationDialog
       title={"timeline.dialogChangeProperty.title"}
@@ -78,6 +75,5 @@ const TimelinePropertyChangeDialog: React.FC<
       }}
     />
   );
-};
+}
 
-export default TimelinePropertyChangeDialog;
diff --git a/FrontEnd/src/pages/timeline/edit/ImagePostEdit.css b/FrontEnd/src/pages/timeline/edit/ImagePostEdit.css
new file mode 100644
index 00000000..3d5e895c
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/edit/ImagePostEdit.css
@@ -0,0 +1,4 @@
+.timeline-post-create-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
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>
+  );
+}
diff --git a/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.css b/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.css
new file mode 100644
index 00000000..33a77943
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.css
@@ -0,0 +1,34 @@
+.timeline-markdown-post-edit-page {
+  overflow: auto;
+  max-height: 300px;
+}
+
+.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-post-create-markdown-edit-area:hover {
+  border: 1px solid var(--cru-clickable-primary-normal-color);
+  border-top: none;
+}
+
+.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;
+}
diff --git a/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.tsx b/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.tsx
new file mode 100644
index 00000000..d10d3f2d
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.tsx
@@ -0,0 +1,216 @@
+import * as React from "react";
+import classnames from "classnames";
+import { useTranslation } from "react-i18next";
+
+import {
+  getHttpTimelineClient,
+  HttpTimelinePostInfo,
+} from "~src/http/timeline";
+
+import TimelinePostBuilder from "~src/services/TimelinePostBuilder";
+
+import FlatButton from "~src/components/button/FlatButton";
+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 "./MarkdownPostEdit.css";
+
+export interface MarkdownPostEditProps {
+  owner: string;
+  timeline: string;
+  onPosted: (post: HttpTimelinePostInfo) => void;
+  onPostError: () => void;
+  onClose: () => void;
+  className?: string;
+}
+
+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;
+  };
+
+  const canSend = text.length > 0;
+
+  React.useEffect(() => {
+    return () => {
+      getBuilder().dispose();
+    };
+  }, []);
+
+  React.useEffect(() => {
+    window.onbeforeunload = (): unknown => {
+      if (!canLeave) {
+        return t("timeline.confirmLeave");
+      }
+    };
+
+    return () => {
+      window.onbeforeunload = null;
+    };
+  }, [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();
+    }
+  };
+
+  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");
+                  }
+                }}
+              />
+              {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} />
+    </>
+  );
+};
+
+export default MarkdownPostEdit;
diff --git a/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.css b/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.css
new file mode 100644
index 00000000..0f2b9dbd
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.css
@@ -0,0 +1,18 @@
+.timeline-post-create-edit-text {
+  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;
+}
+
+.timeline-post-create-edit-text:hover {
+  border-color: var(--cru-clickable-secondary-normal-color);
+}
+
+.timeline-post-create-edit-text: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
new file mode 100644
index 00000000..1bea3daf
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.tsx
@@ -0,0 +1,26 @@
+import classNames from 'classnames'
+
+interface TimelinePostEditTextProps {
+  text: string;
+  disabled: boolean;
+  onChange: (text: string) => void;
+  className?: string;
+}
+
+export default function TimelinePostEditText(props: TimelinePostEditTextProps) {
+  const { text, disabled, onChange, className } = props;
+
+  return (
+    <div className={classNames("timeline-post-create-edit-container", className)}>
+      <textarea
+        value={text}
+        disabled={disabled}
+        onChange={(event) => {
+          onChange(event.target.value);
+        }}
+        className={classNames("timeline-post-create-edit-text")}
+      />
+    </div>
+  );
+}
+
diff --git a/FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.css b/FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.css
new file mode 100644
index 00000000..5e93d9f2
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.css
@@ -0,0 +1,33 @@
+.timeline-post-create-card {
+  position: sticky !important;
+  top: 106px;
+  z-index: 100;
+  margin-right: 200px;
+}
+
+@media (max-width: 576px) {
+  .timeline-post-create-container {
+    padding-top: 60px;
+  }
+
+  .timeline-post-create-card{
+    margin-right: 0;
+  }
+}
+
+.timeline-post-create {
+  display: flex;
+}
+
+.timeline-post-create-edit-area {
+  flex-grow: 1;
+}
+
+
+
+.timeline-post-create-right-area {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  margin-left: 1em;
+}
diff --git a/FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.tsx b/FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.tsx
new file mode 100644
index 00000000..45d742f7
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.tsx
@@ -0,0 +1,200 @@
+import { useState, useEffect, ChangeEventHandler } from "react";
+import { useTranslation } from "react-i18next";
+import classNames from "classnames";
+
+import { UiLogicError } from "~src/common";
+
+import {
+  getHttpTimelineClient,
+  HttpTimelineInfo,
+  HttpTimelinePostInfo,
+  HttpTimelinePostPostRequestData,
+} from "~src/http/timeline";
+
+import base64 from "~src/utilities/base64";
+
+import { pushAlert } from "~src/components/alert";
+import LoadingButton from "~src/components/button/LoadingButton";
+import PopupMenu from "~src/components/menu/PopupMenu";
+import TimelinePostCard from "../TimelinePostCard";
+import TimelinePostContainer from "../TimelinePostContainer";
+import IconButton from "~src/components/button/IconButton";
+
+import PlainTextPostEdit from './PlainTextPostEdit'
+import MarkdownPostEdit from "./MarkdownPostEdit";
+
+import "./TimelinePostCreateView.css";
+
+
+
+
+
+type PostKind = "text" | "markdown" | "image";
+
+const postKindIconMap: Record<PostKind, string> = {
+  text: "fonts",
+  markdown: "markdown",
+  image: "image",
+};
+
+export interface TimelinePostEditProps {
+  className?: string;
+  timeline: HttpTimelineInfo;
+  onPosted: (newPost: HttpTimelinePostInfo) => void;
+}
+
+function TimelinePostEdit(props: TimelinePostEditProps) {
+  const { timeline, className, onPosted } = props;
+
+  const { t } = useTranslation();
+
+  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 draftTextLocalStorageKey = `timeline.${timeline.owner.username}.${timeline.nameV2}.postDraft.text`;
+
+  useEffect(() => {
+    setText(window.localStorage.getItem(draftTextLocalStorageKey) ?? "");
+  }, [draftTextLocalStorageKey]);
+
+  const canSend =
+    (kind === "text" && text.length !== 0) ||
+    (kind === "image" && image != null);
+
+  const onPostError = (): void => {
+    pushAlert({
+      color: "danger",
+      message: "timeline.sendPostFailed",
+    });
+  };
+
+  const onSend = async (): Promise<void> => {
+    setProcess(true);
+
+    let requestData: HttpTimelinePostPostRequestData;
+    switch (kind) {
+      case "text":
+        requestData = {
+          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.",
+          );
+        }
+        requestData = {
+          contentType: image.type,
+          data: await base64(image),
+        };
+        break;
+      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();
+        },
+      );
+  };
+
+  return (
+    <TimelinePostContainer
+      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>
+        )}
+      </TimelinePostCard>
+    </TimelinePostContainer>
+  );
+}
+
+export default TimelinePostEdit;
-- 
cgit v1.2.3

