diff options
Diffstat (limited to 'FrontEnd/src/pages/timeline/edit')
8 files changed, 567 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..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; |