From 40b4871c3f7bfe04f332ae7fb687fd7d9ae34734 Mon Sep 17 00:00:00 2001 From: crupest Date: Thu, 14 Sep 2023 23:47:16 +0800 Subject: ... --- .../pages/timeline/edit/TimelinePostCreateView.tsx | 200 +++++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.tsx (limited to 'FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.tsx') 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 = { + 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(false); + + const [kind, setKind] = useState>("text"); + const [showMarkdown, setShowMarkdown] = useState(false); + + const [text, setText] = useState(""); + const [image, setImage] = useState(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 => { + 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 ( + + + {showMarkdown ? ( + setShowMarkdown(false)} + owner={timeline.owner.username} + timeline={timeline.nameV2} + onPosted={onPosted} + onPostError={onPostError} + /> + ) : ( +
+
+ {(() => { + if (kind === "text") { + return ( + { + setText(text); + window.localStorage.setItem( + draftTextLocalStorageKey, + text, + ); + }} + /> + ); + } else if (kind === "image") { + return ( + + ); + } + })()} +
+
+ ({ + type: "button", + text: `timeline.post.type.${kind}`, + iconClassName: postKindIconMap[kind], + onClick: () => { + if (kind === "markdown") { + setShowMarkdown(true); + } else { + setKind(kind); + } + }, + }))} + > + + + void onSend()} + color="primary" + disabled={!canSend} + loading={process} + > + {t("timeline.send")} + +
+
+ )} +
+
+ ); +} + +export default TimelinePostEdit; -- cgit v1.2.3 From 754597d49cd2d3f6295e5fe3ed68c6210bf4e8a5 Mon Sep 17 00:00:00 2001 From: crupest 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/TimelinePostCreateView.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 (
- {file && } + {file && }
); } 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 = ({ - owner: ownerUsername, - timeline: timelineName, - onPosted, - onClose, - onPostError, - className, -}) => { - const { t } = useTranslation(); - - const [canLeave, setCanLeave] = React.useState(true); - - const [process, setProcess] = React.useState(false); - - const { controller, switchDialog } = useDialog({ - "leave-confirm": ( - - ), - }); - - const [text, _setText] = React.useState(""); - const [images, _setImages] = React.useState<{ file: File; url: string }[]>( - [], - ); - const [previewHtml, _setPreviewHtml] = React.useState(""); - - const _builder = React.useRef(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 { + 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; + markdownEditProps: Omit; +} { + const [text, setText] = useState(""); + const [images, setImages] = useState([]); + + 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 => { - 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 ( - <> - - ) : ( -
- { - if (canLeave) { - onClose(); - } else { - switchDialog("leave-confirm"); +
+ ); +} + +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 ( + { + onTextChange(event.currentTarget.value); + }} + /> + ), + }, + { + name: "images", + text: "image", + page: ( +
+ {images.map((image, index) => ( +
+ + { + onImageDelete(index); + }} + /> +
+ ))} + ) => { + const { files } = event.currentTarget; + if (files != null && files.length !== 0) { + onImageAppend(files[0]); } }} + disabled={disabled} /> - {canSend && ( - void send()} /> - )}
- ) - } - pages={[ - { - name: "text", - text: "edit", - page: ( -