aboutsummaryrefslogtreecommitdiff
path: root/FrontEnd/src/pages/timeline/edit
diff options
context:
space:
mode:
Diffstat (limited to 'FrontEnd/src/pages/timeline/edit')
-rw-r--r--FrontEnd/src/pages/timeline/edit/ImagePostEdit.css4
-rw-r--r--FrontEnd/src/pages/timeline/edit/ImagePostEdit.tsx36
-rw-r--r--FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.css34
-rw-r--r--FrontEnd/src/pages/timeline/edit/MarkdownPostEdit.tsx216
-rw-r--r--FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.css18
-rw-r--r--FrontEnd/src/pages/timeline/edit/PlainTextPostEdit.tsx26
-rw-r--r--FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.css33
-rw-r--r--FrontEnd/src/pages/timeline/edit/TimelinePostCreateView.tsx200
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;