diff options
-rw-r--r-- | FrontEnd/src/app/index.sass | 22 | ||||
-rw-r--r-- | FrontEnd/src/app/locales/en/translation.json | 4 | ||||
-rw-r--r-- | FrontEnd/src/app/locales/zh/translation.json | 4 | ||||
-rw-r--r-- | FrontEnd/src/app/services/TimelinePostBuilder.ts | 42 | ||||
-rw-r--r-- | FrontEnd/src/app/views/common/ConfirmDialog.tsx | 40 | ||||
-rw-r--r-- | FrontEnd/src/app/views/common/FlatButton.tsx | 36 | ||||
-rw-r--r-- | FrontEnd/src/app/views/common/TabPages.tsx | 74 | ||||
-rw-r--r-- | FrontEnd/src/app/views/common/common.sass | 4 | ||||
-rw-r--r-- | FrontEnd/src/app/views/timeline-common/MarkdownPostEdit.tsx | 187 | ||||
-rw-r--r-- | FrontEnd/src/app/views/timeline-common/TimelinePostContentView.tsx | 2 | ||||
-rw-r--r-- | FrontEnd/src/app/views/timeline-common/TimelinePostEdit.tsx | 167 | ||||
-rw-r--r-- | FrontEnd/src/app/views/timeline-common/timeline-common.sass | 8 |
12 files changed, 474 insertions, 116 deletions
diff --git a/FrontEnd/src/app/index.sass b/FrontEnd/src/app/index.sass index 39dce693..2079cad8 100644 --- a/FrontEnd/src/app/index.sass +++ b/FrontEnd/src/app/index.sass @@ -45,8 +45,15 @@ small cursor: pointer
padding: 0.2em 0.5em
border-radius: 0.2em
- &:hover
+ &:hover:not(.disabled)
background-color: $gray-200
+ &.disabled
+ cursor: default
+ @each $color, $value in $theme-colors
+ &.#{$color}
+ color: $value
+ &.disabled
+ color: adjust-color($value, $lightness: +15%)
.cursor-pointer
cursor: pointer
@@ -81,10 +88,10 @@ textarea .text-yellow
color: $yellow
-@each $color, $value in $theme-colors
- .text-button
- background: transparent
- border: none
+.text-button
+ background: transparent
+ border: none
+ @each $color, $value in $theme-colors
&.#{$color}
color: $value
&:hover
@@ -95,3 +102,8 @@ textarea i
line-height: 1
+
+.markdown-container
+ img
+ max-height: 200px
+ max-width: 100%
diff --git a/FrontEnd/src/app/locales/en/translation.json b/FrontEnd/src/app/locales/en/translation.json index 4002ee4f..65ddbe0c 100644 --- a/FrontEnd/src/app/locales/en/translation.json +++ b/FrontEnd/src/app/locales/en/translation.json @@ -2,7 +2,9 @@ "welcome": "Welcome!", "search": "Search", "edit": "Edit", + "image": "Image", "done": "Done", + "preview": "Preview", "loadFailReload": "Load failed, <1>click here to reload</1>.", "error": { "network": "Network error.", @@ -65,6 +67,8 @@ "send": "Send", "deletePostFailed": "Failed to delete post.", "sendPostFailed": "Failed to send post.", + "dropDraft": "Drop Draft", + "confirmLeave": "Are you sure to leave? All content you typed would be lost.", "visibility": { "public": "public to everyone", "register": "only registed people can see", diff --git a/FrontEnd/src/app/locales/zh/translation.json b/FrontEnd/src/app/locales/zh/translation.json index 3f966d7c..f6971241 100644 --- a/FrontEnd/src/app/locales/zh/translation.json +++ b/FrontEnd/src/app/locales/zh/translation.json @@ -2,7 +2,9 @@ "welcome": "欢迎!", "search": "搜索", "edit": "编辑", + "image": "图片", "done": "完成", + "preview": "预览", "loadFailReload": "加载失败,<1>点击重试</1>。", "error": { "network": "网络错误。", @@ -65,6 +67,8 @@ "send": "发送", "deletePostFailed": "删除消息失败。", "sendPostFailed": "发送消息失败。", + "dropDraft": "放弃草稿", + "confirmLeave": "确定要离开吗?所有输入的内容将会丢失。", "visibility": { "public": "对所有人公开", "register": "仅注册可见", diff --git a/FrontEnd/src/app/services/TimelinePostBuilder.ts b/FrontEnd/src/app/services/TimelinePostBuilder.ts index 8594fa49..40279eca 100644 --- a/FrontEnd/src/app/services/TimelinePostBuilder.ts +++ b/FrontEnd/src/app/services/TimelinePostBuilder.ts @@ -1,8 +1,3 @@ -import { - escapeHtml, - replaceEntities, - unescapeMd, -} from "remarkable/lib/common/utils"; import { Remarkable } from "remarkable"; import { UiLogicError } from "@/common"; @@ -10,7 +5,7 @@ import { UiLogicError } from "@/common"; import { base64 } from "@/http/common"; import { HttpTimelinePostPostRequest } from "@/http/timeline"; -export class TimelinePostBuilder { +export default class TimelinePostBuilder { private _onChange: () => void; private _text = ""; private _images: { file: File; url: string }[] = []; @@ -18,28 +13,16 @@ export class TimelinePostBuilder { constructor(onChange: () => void) { this._onChange = onChange; + const oldImageRenderer = this._md.renderer.rules.image; this._md.renderer.rules.image = (( _t: TimelinePostBuilder ): Remarkable.Rule<Remarkable.ImageToken, string> => function (tokens, idx, options /*, env */) { const i = parseInt(tokens[idx].src); - const src = - ' src="' + - (isNaN(i) && i > 0 && i <= _t._images.length - ? escapeHtml(tokens[idx].src) - : _t._images[i - 1].url) + - '"'; - const title = tokens[idx].title - ? ' title="' + escapeHtml(replaceEntities(tokens[idx].title)) + '"' - : ""; - const alt = - ' alt="' + - (tokens[idx].alt - ? escapeHtml(replaceEntities(unescapeMd(tokens[idx].alt))) - : "") + - '"'; - const suffix = options?.xhtmlOut ? " /" : ""; - return "<img" + src + alt + title + suffix + ">"; + if (!isNaN(i) && i > 0 && i <= _t._images.length) { + tokens[idx].src = _t._images[i - 1].url; + } + return oldImageRenderer(tokens, idx, options); })(this); } @@ -49,6 +32,7 @@ export class TimelinePostBuilder { } appendImage(file: File): void { + this._images = this._images.slice(); this._images.push({ file, url: URL.createObjectURL(file), @@ -69,6 +53,8 @@ export class TimelinePostBuilder { newIndex = this._images.length - 1; } + this._images = this._images.slice(); + const [old] = this._images.splice(oldIndex, 1); this._images.splice(newIndex, 0, old); @@ -80,16 +66,26 @@ export class TimelinePostBuilder { 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 this._md.render(this._text); } diff --git a/FrontEnd/src/app/views/common/ConfirmDialog.tsx b/FrontEnd/src/app/views/common/ConfirmDialog.tsx new file mode 100644 index 00000000..72940c51 --- /dev/null +++ b/FrontEnd/src/app/views/common/ConfirmDialog.tsx @@ -0,0 +1,40 @@ +import { convertI18nText, I18nText } from "@/common"; +import React from "react"; +import { Modal, Button } from "react-bootstrap"; +import { useTranslation } from "react-i18next"; + +const ConfirmDialog: React.FC<{ + onClose: () => void; + onConfirm: () => void; + title: I18nText; + body: I18nText; +}> = ({ onClose, onConfirm, title, body }) => { + const { t } = useTranslation(); + + return ( + <Modal onHide={onClose} show centered> + <Modal.Header> + <Modal.Title className="text-danger"> + {convertI18nText(title, t)} + </Modal.Title> + </Modal.Header> + <Modal.Body>{convertI18nText(body, t)}</Modal.Body> + <Modal.Footer> + <Button variant="secondary" onClick={onClose}> + {t("operationDialog.cancel")} + </Button> + <Button + variant="danger" + onClick={() => { + onConfirm(); + onClose(); + }} + > + {t("operationDialog.confirm")} + </Button> + </Modal.Footer> + </Modal> + ); +}; + +export default ConfirmDialog; diff --git a/FrontEnd/src/app/views/common/FlatButton.tsx b/FrontEnd/src/app/views/common/FlatButton.tsx new file mode 100644 index 00000000..80bb654c --- /dev/null +++ b/FrontEnd/src/app/views/common/FlatButton.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import clsx from "clsx"; + +import { BootstrapThemeColor } from "@/common"; + +export interface FlatButtonProps { + variant?: BootstrapThemeColor | string; + disabled?: boolean; + className?: string; + style?: React.CSSProperties; + onClick?: () => void; +} + +const FlatButton: React.FC<FlatButtonProps> = (props) => { + const { disabled, className, style } = props; + const variant = props.variant ?? "primary"; + + const onClick = disabled ? undefined : props.onClick; + + return ( + <div + className={clsx( + "flat-button", + variant, + disabled ? "disabled" : null, + className + )} + style={style} + onClick={onClick} + > + {props.children} + </div> + ); +}; + +export default FlatButton; diff --git a/FrontEnd/src/app/views/common/TabPages.tsx b/FrontEnd/src/app/views/common/TabPages.tsx new file mode 100644 index 00000000..424e769f --- /dev/null +++ b/FrontEnd/src/app/views/common/TabPages.tsx @@ -0,0 +1,74 @@ +import React from "react"; +import { Nav } from "react-bootstrap"; +import { useTranslation } from "react-i18next"; + +import { convertI18nText, I18nText, UiLogicError } from "@/common"; + +export interface TabPage { + id: string; + tabText: I18nText; + page: React.ReactNode; +} + +export interface TabPagesProps { + pages: TabPage[]; + actions?: React.ReactNode; + className?: string; + style?: React.CSSProperties; + navClassName?: string; + navStyle?: React.CSSProperties; + pageContainerClassName?: string; + pageContainerStyle?: React.CSSProperties; +} + +const TabPages: React.FC<TabPagesProps> = ({ + pages, + actions, + className, + style, + navClassName, + navStyle, + pageContainerClassName, + pageContainerStyle, +}) => { + if (pages.length === 0) { + throw new UiLogicError("Page list can't be empty."); + } + + const { t } = useTranslation(); + + const [tab, setTab] = React.useState<string>(pages[0].id); + + const currentPage = pages.find((p) => p.id === tab); + + if (currentPage == null) { + throw new UiLogicError("Current tab value is bad."); + } + + return ( + <div className={className} style={style}> + <Nav variant="tabs" className={navClassName} style={navStyle}> + {pages.map((page) => ( + <Nav.Item key={page.id}> + <Nav.Link + active={tab === page.id} + onClick={() => { + setTab(page.id); + }} + > + {convertI18nText(page.tabText, t)} + </Nav.Link> + </Nav.Item> + ))} + {actions != null && ( + <div className="ml-auto cru-tab-pages-action-area">{actions}</div> + )} + </Nav> + <div className={pageContainerClassName} style={pageContainerStyle}> + {currentPage.page} + </div> + </div> + ); +}; + +export default TabPages; diff --git a/FrontEnd/src/app/views/common/common.sass b/FrontEnd/src/app/views/common/common.sass index 0a30d995..f3022d19 100644 --- a/FrontEnd/src/app/views/common/common.sass +++ b/FrontEnd/src/app/views/common/common.sass @@ -92,3 +92,7 @@ .cru-menu-divider
border-top: 1px solid $gray-200
+
+.cru-tab-pages-action-area
+ display: flex
+ align-items: center
diff --git a/FrontEnd/src/app/views/timeline-common/MarkdownPostEdit.tsx b/FrontEnd/src/app/views/timeline-common/MarkdownPostEdit.tsx new file mode 100644 index 00000000..f4351db0 --- /dev/null +++ b/FrontEnd/src/app/views/timeline-common/MarkdownPostEdit.tsx @@ -0,0 +1,187 @@ +import React from "react"; +import { Form } from "react-bootstrap"; +import { useTranslation } from "react-i18next"; +import { Prompt } from "react-router"; + +import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline"; + +import FlatButton from "../common/FlatButton"; +import TabPages from "../common/TabPages"; +import TimelinePostBuilder from "@/services/TimelinePostBuilder"; +import ConfirmDialog from "../common/ConfirmDialog"; + +export interface MarkdownPostEditProps { + timeline: string; + onPosted: (post: HttpTimelinePostInfo) => void; + onPostError: () => void; + onClose: () => void; + className?: string; + style?: React.CSSProperties; +} + +const MarkdownPostEdit: React.FC<MarkdownPostEditProps> = ({ + timeline: timelineName, + onPosted, + onClose, + onPostError, + className, + style, +}) => { + const { t } = useTranslation(); + + const [canLeave, setCanLeave] = React.useState<boolean>(true); + + const [process, setProcess] = React.useState<boolean>(false); + + const [ + showLeaveConfirmDialog, + setShowLeaveConfirmDialog, + ] = React.useState<boolean>(false); + + 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; + }; + + React.useEffect(() => { + return () => { + getBuilder().dispose(); + }; + }, []); + + React.useEffect(() => { + window.onbeforeunload = () => { + 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(timelineName, { + dataList, + }); + onPosted(post); + onClose(); + } catch (e) { + setProcess(false); + onPostError(); + } + }; + + return ( + <> + <Prompt when={!canLeave} message={t("timeline.confirmLeave")} /> + <TabPages + className={className} + style={style} + pageContainerClassName="py-2" + actions={ + <> + <FlatButton + className="mr-2" + variant="danger" + onClick={() => { + if (canLeave) { + onClose(); + } else { + setShowLeaveConfirmDialog(true); + } + }} + > + {t("operationDialog.cancel")} + </FlatButton> + <FlatButton onClick={send} disabled={canLeave}> + {t("timeline.send")} + </FlatButton> + </> + } + pages={[ + { + id: "text", + tabText: "edit", + page: ( + <Form.Control + as="textarea" + value={text} + disabled={process} + onChange={(event) => { + getBuilder().setMarkdownText(event.currentTarget.value); + }} + /> + ), + }, + { + id: "images", + tabText: "image", + page: ( + <div className="timeline-markdown-post-edit-page"> + {images.map((image) => ( + <img + key={image.url} + src={image.url} + className="timeline-markdown-post-edit-image" + /> + ))} + <Form.File + label={t("chooseImage")} + accept="image/*" + onChange={(event: React.ChangeEvent<HTMLInputElement>) => { + const { files } = event.currentTarget; + if (files != null && files.length !== 0) { + getBuilder().appendImage(files[0]); + } + }} + disabled={process} + /> + </div> + ), + }, + { + id: "preview", + tabText: "preview", + page: ( + <div + className="markdown-container timeline-markdown-post-edit-page" + dangerouslySetInnerHTML={{ __html: previewHtml }} + /> + ), + }, + ]} + /> + {showLeaveConfirmDialog && ( + <ConfirmDialog + onClose={() => setShowLeaveConfirmDialog(false)} + onConfirm={onClose} + title="timeline.dropDraft" + body="timeline.confirmLeave" + /> + )} + </> + ); +}; + +export default MarkdownPostEdit; diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePostContentView.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePostContentView.tsx index d836d1db..58fae4c7 100644 --- a/FrontEnd/src/app/views/timeline-common/TimelinePostContentView.tsx +++ b/FrontEnd/src/app/views/timeline-common/TimelinePostContentView.tsx @@ -152,7 +152,7 @@ const MarkdownView: React.FC<TimelinePostContentViewProps> = (props) => { } return ( <div - className={className} + className={clsx(className, "markdown-container")} style={style} dangerouslySetInnerHTML={{ __html: markdownHtml, diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePostEdit.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePostEdit.tsx index 025b41c0..a474d2f6 100644 --- a/FrontEnd/src/app/views/timeline-common/TimelinePostEdit.tsx +++ b/FrontEnd/src/app/views/timeline-common/TimelinePostEdit.tsx @@ -18,6 +18,7 @@ import { base64 } from "@/http/common"; import BlobImage from "../common/BlobImage"; import LoadingButton from "../common/LoadingButton"; import { PopupMenu } from "../common/Menu"; +import MarkdownPostEdit from "./MarkdownPostEdit"; interface TimelinePostEditTextProps { text: string; @@ -121,26 +122,21 @@ const TimelinePostEdit: React.FC<TimelinePostEditProps> = (props) => { const [process, setProcess] = React.useState<boolean>(false); - const [kind, setKind] = React.useState<PostKind>("text"); + const [kind, setKind] = React.useState<Exclude<PostKind, "markdown">>("text"); + const [showMarkdown, setShowMarkdown] = React.useState<boolean>(false); const [text, setText] = React.useState<string>(""); - const [markdown, setMarkdown] = React.useState<string>(""); const [image, setImage] = React.useState<File | null>(null); const draftTextLocalStorageKey = `timeline.${timeline.name}.postDraft.text`; - const draftMarkdownLocalStorageKey = `timeline.${timeline.name}.postDraft.markdown`; React.useEffect(() => { setText(window.localStorage.getItem(draftTextLocalStorageKey) ?? ""); - setMarkdown( - window.localStorage.getItem(draftMarkdownLocalStorageKey) ?? "" - ); - }, [draftTextLocalStorageKey, draftMarkdownLocalStorageKey]); + }, [draftTextLocalStorageKey]); const canSend = (kind === "text" && text.length !== 0) || - (kind === "image" && image != null) || - (kind === "markdown" && markdown.length !== 0); + (kind === "image" && image != null); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const containerRef = React.useRef<HTMLDivElement>(null!); @@ -160,6 +156,13 @@ const TimelinePostEdit: React.FC<TimelinePostEditProps> = (props) => { }; }); + const onPostError = (): void => { + pushAlert({ + type: "danger", + message: "timeline.sendPostFailed", + }); + }; + const onSend = async (): Promise<void> => { setProcess(true); @@ -171,12 +174,6 @@ const TimelinePostEdit: React.FC<TimelinePostEditProps> = (props) => { data: await base64(text), }; break; - case "markdown": - requestData = { - contentType: "text/markdown", - data: await base64(markdown), - }; - break; case "image": if (image == null) { throw new UiLogicError( @@ -201,20 +198,14 @@ const TimelinePostEdit: React.FC<TimelinePostEditProps> = (props) => { if (kind === "text") { setText(""); window.localStorage.removeItem(draftTextLocalStorageKey); - } else if (kind === "markdown") { - setMarkdown(""); - window.localStorage.removeItem(draftMarkdownLocalStorageKey); } setProcess(false); setKind("text"); onPosted(data); }, (_) => { - pushAlert({ - type: "danger", - message: "timeline.sendPostFailed", - }); setProcess(false); + onPostError(); } ); }; @@ -224,73 +215,75 @@ const TimelinePostEdit: React.FC<TimelinePostEditProps> = (props) => { ref={containerRef} className={clsx("container-fluid bg-light", className)} > - <Row> - <Col className="px-1 py-1"> - {(() => { - if (kind === "text") { - return ( - <TimelinePostEditText - className="w-100 h-100 timeline-post-edit" - text={text} - disabled={process} - onChange={(t) => { - setText(t); - window.localStorage.setItem(draftTextLocalStorageKey, t); - }} - /> - ); - } else if (kind === "image") { - return ( - <TimelinePostEditImage onSelect={setImage} disabled={process} /> - ); - } else if (kind === "markdown") { - return ( - <TimelinePostEditText - className="w-100 h-100 timeline-post-edit" - text={markdown} - disabled={process} - onChange={(t) => { - setMarkdown(t); - window.localStorage.setItem( - draftMarkdownLocalStorageKey, - t - ); - }} + {showMarkdown ? ( + <MarkdownPostEdit + className="w-100" + onClose={() => setShowMarkdown(false)} + timeline={timeline.name} + onPosted={onPosted} + onPostError={onPostError} + /> + ) : ( + <Row> + <Col className="px-1 py-1"> + {(() => { + if (kind === "text") { + return ( + <TimelinePostEditText + className="w-100 h-100 timeline-post-edit" + text={text} + disabled={process} + onChange={(t) => { + setText(t); + window.localStorage.setItem(draftTextLocalStorageKey, t); + }} + /> + ); + } else if (kind === "image") { + return ( + <TimelinePostEditImage + onSelect={setImage} + disabled={process} + /> + ); + } + })()} + </Col> + <Col xs="auto" className="align-self-end m-1"> + <div className="d-block text-center mt-1 mb-2"> + <PopupMenu + items={(["text", "image", "markdown"] as const).map((kind) => ({ + type: "button", + text: `timeline.post.type.${kind}`, + iconClassName: postKindIconClassNameMap[kind], + onClick: () => { + if (kind === "markdown") { + setShowMarkdown(true); + } else { + setKind(kind); + } + }, + }))} + > + <i + className={clsx( + postKindIconClassNameMap[kind], + "icon-button large" + )} /> - ); - } - })()} - </Col> - <Col xs="auto" className="align-self-end m-1"> - <div className="d-block text-center mt-1 mb-2"> - <PopupMenu - items={(["text", "image", "markdown"] as const).map((kind) => ({ - type: "button", - text: `timeline.post.type.${kind}`, - iconClassName: postKindIconClassNameMap[kind], - onClick: () => { - setKind(kind); - }, - }))} + </PopupMenu> + </div> + <LoadingButton + variant="primary" + onClick={onSend} + disabled={!canSend} + loading={process} > - <i - className={clsx( - postKindIconClassNameMap[kind], - "icon-button large" - )} - /> - </PopupMenu> - </div> - <LoadingButton - variant="primary" - onClick={onSend} - disabled={!canSend} - loading={process} - > - {t("timeline.send")} - </LoadingButton> - </Col> - </Row> + {t("timeline.send")} + </LoadingButton> + </Col> + </Row> + )} </div> ); }; diff --git a/FrontEnd/src/app/views/timeline-common/timeline-common.sass b/FrontEnd/src/app/views/timeline-common/timeline-common.sass index 43c4d0f7..04318674 100644 --- a/FrontEnd/src/app/views/timeline-common/timeline-common.sass +++ b/FrontEnd/src/app/views/timeline-common/timeline-common.sass @@ -170,3 +170,11 @@ $timeline-line-color-current: #36c2e6 top: 56px right: 0 margin: 0.5em + +.timeline-markdown-post-edit-page + overflow: scroll + max-height: 300px + +.timeline-markdown-post-edit-image + max-width: 100% + max-height: 200px |