aboutsummaryrefslogtreecommitdiff
path: root/FrontEnd/src/pages/timeline/edit
diff options
context:
space:
mode:
authorcrupest <crupest@outlook.com>2023-09-20 20:26:42 +0800
committerGitHub <noreply@github.com>2023-09-20 20:26:42 +0800
commitf836d77e73f3ea0af45c5f71dae7268143d6d86f (patch)
tree573cfafd972106d69bef0d41ff5f270ec3c43ec2 /FrontEnd/src/pages/timeline/edit
parent4a069bf1268f393d5467166356f691eb89963152 (diff)
parent901fe3d7c032d284da5c9bce24c4aaee9054c7ac (diff)
downloadtimeline-f836d77e73f3ea0af45c5f71dae7268143d6d86f.tar.gz
timeline-f836d77e73f3ea0af45c5f71dae7268143d6d86f.tar.bz2
timeline-f836d77e73f3ea0af45c5f71dae7268143d6d86f.zip
Merge pull request #1395 from crupest/dev
Refector 2023 v0.1
Diffstat (limited to 'FrontEnd/src/pages/timeline/edit')
-rw-r--r--FrontEnd/src/pages/timeline/edit/ImagePostEdit.css5
-rw-r--r--FrontEnd/src/pages/timeline/edit/ImagePostEdit.tsx36
-rw-r--r--FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.css24
-rw-r--r--FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.tsx199
-rw-r--r--FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.css12
-rw-r--r--FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.tsx29
-rw-r--r--FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.css35
-rw-r--r--FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.tsx193
8 files changed, 533 insertions, 0 deletions
diff --git a/FrontEnd/src/pages/timeline/edit/ImagePostEdit.css b/FrontEnd/src/pages/timeline/edit/ImagePostEdit.css
new file mode 100644
index 00000000..232681c8
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/edit/ImagePostEdit.css
@@ -0,0 +1,5 @@
+.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
new file mode 100644
index 00000000..c62c8ee5
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/edit/ImagePostEdit.tsx
@@ -0,0 +1,36 @@
+import classNames from "classnames";
+
+import BlobImage from "~/src/components/BlobImage";
+
+import "./ImagePostEdit.css";
+
+interface TimelinePostEditImageProps {
+ file: File | null;
+ 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-edit-image-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="timeline-edit-image-input"
+ />
+ {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
new file mode 100644
index 00000000..c5b41b40
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.css
@@ -0,0 +1,24 @@
+.timeline-edit-markdown-tab-page {
+ min-height: 8em;
+ display: flex;
+}
+
+.timeline-edit-markdown-text {
+ width: 100%;
+}
+
+.timeline-edit-markdown-images {
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.timeline-edit-markdown-images img {
+ max-width: 100%;
+ max-height: 200px;
+}
+
+.timeline-edit-markdown-preview img {
+ max-width: 100%;
+ max-height: 200px;
+}
+
diff --git a/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.tsx b/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.tsx
new file mode 100644
index 00000000..36a5572b
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.tsx
@@ -0,0 +1,199 @@
+import { useEffect, useState } from "react";
+import classnames from "classnames";
+import { marked } from "marked";
+
+import { HttpTimelinePostPostRequestData } from "~src/http/timeline";
+
+import base64 from "~src/utilities/base64";
+
+import { array } from "~src/components/common";
+import { TabPages } from "~src/components/tab";
+import { IconButton } from "~src/components/button";
+import BlobImage from "~src/components/BlobImage";
+
+import "./MarkdownPostEdit.css";
+
+class MarkedRenderer extends marked.Renderer {
+ constructor(public images: string[]) {
+ super();
+ }
+
+ // Custom image parser for indexed image link.
+ image(href: string, title: string | null, text: string): string {
+ const i = parseInt(href);
+ if (!isNaN(i) && i > 0 && i <= this.images.length) {
+ href = this.images[i - 1];
+ }
+
+ return super.image(href, title, text);
+ }
+}
+
+function generateMarkedOptions(imageUrls: string[]) {
+ return {
+ renderer: new MarkedRenderer(imageUrls),
+ async: false,
+ } as const;
+}
+
+function renderHtml(text: string, imageUrls: string[]): string {
+ return marked.parse(text, generateMarkedOptions(imageUrls));
+}
+
+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 };
+ }),
+ )),
+ ];
+}
+
+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 () => {
+ imageUrls.forEach((url) => URL.revokeObjectURL(url));
+ };
+ }, [text, images]);
+
+ return (
+ <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}
+ pageContainerClassName="timeline-edit-markdown-tab-page"
+ 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}
+ />
+ </div>
+ ),
+ },
+ {
+ name: "preview",
+ text: "preview",
+ page: <MarkdownPreview text={text} images={images} />,
+ },
+ ]}
+ />
+ );
+}
diff --git a/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.css b/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.css
new file mode 100644
index 00000000..d1a61793
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.css
@@ -0,0 +1,12 @@
+.timeline-edit-plain-text-container {
+ width: 100%;
+ height: 100%;
+}
+
+.timeline-edit-plain-text-input {
+ width: 100%;
+ height: 100%;
+ padding: 0.5em;
+ border-radius: 4px;
+}
+
diff --git a/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.tsx b/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.tsx
new file mode 100644
index 00000000..7f3663b2
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.tsx
@@ -0,0 +1,29 @@
+import classNames from "classnames";
+
+import "./PlainTextPostEdit.css";
+
+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-edit-plain-text-container", className)}
+ >
+ <textarea
+ value={text}
+ disabled={disabled}
+ onChange={(event) => {
+ onChange(event.target.value);
+ }}
+ className={classNames("timeline-edit-plain-text-input")}
+ />
+ </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..6efe93e9
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.css
@@ -0,0 +1,35 @@
+.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;
+}
+
+.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
new file mode 100644
index 00000000..c0a80ad0
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.tsx
@@ -0,0 +1,193 @@
+import { useState } from "react";
+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 { useC } from "~/src/components/common";
+import { pushAlert } from "~src/components/alert";
+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 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> = {
+ 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 c = useC();
+
+ const [process, setProcess] = useState<boolean>(false);
+
+ 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);
+
+ useWindowLeave(!mdHasContent && !image);
+
+ const canSend =
+ (kind === "text" && text.length !== 0) ||
+ (kind === "image" && image != null) ||
+ (kind === "markdown" && mdHasContent);
+
+ const onPostError = (): void => {
+ pushAlert({
+ color: "danger",
+ message: "timeline.sendPostFailed",
+ });
+ };
+
+ const onSend = async (): Promise<void> => {
+ setProcess(true);
+
+ let requestDataList: HttpTimelinePostPostRequestData[];
+ switch (kind) {
+ case "text":
+ requestDataList = [
+ {
+ contentType: "text/plain",
+ data: await base64(text),
+ },
+ ];
+ break;
+ case "image":
+ if (image == null) {
+ throw new UiLogicError();
+ }
+ requestDataList = [
+ {
+ contentType: image.type,
+ data: await base64(image),
+ },
+ ];
+ break;
+ case "markdown":
+ if (!mdHasContent) {
+ throw new UiLogicError();
+ }
+ requestDataList = await mdBuild();
+ break;
+ default:
+ throw new UiLogicError("Unknown content type.");
+ }
+
+ 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 (
+ <TimelinePostContainer
+ className={classNames(className, "timeline-post-create-container")}
+ >
+ <TimelinePostCard className="timeline-post-create-card">
+ <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
+ className="timeline-post-create-send"
+ onClick={() => void onSend()}
+ color="primary"
+ disabled={!canSend}
+ loading={process}
+ >
+ {c("timeline.send")}
+ </LoadingButton>
+ </div>
+ </div>
+ </TimelinePostCard>
+ </TimelinePostContainer>
+ );
+}
+
+export default TimelinePostEdit;