diff options
author | crupest <crupest@outlook.com> | 2023-09-20 20:26:42 +0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-09-20 20:26:42 +0800 |
commit | f836d77e73f3ea0af45c5f71dae7268143d6d86f (patch) | |
tree | 573cfafd972106d69bef0d41ff5f270ec3c43ec2 /FrontEnd/src/pages/timeline/edit | |
parent | 4a069bf1268f393d5467166356f691eb89963152 (diff) | |
parent | 901fe3d7c032d284da5c9bce24c4aaee9054c7ac (diff) | |
download | timeline-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')
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; |