aboutsummaryrefslogtreecommitdiff
path: root/FrontEnd/src/views/timeline
diff options
context:
space:
mode:
Diffstat (limited to 'FrontEnd/src/views/timeline')
-rw-r--r--FrontEnd/src/views/timeline/CollapseButton.tsx23
-rw-r--r--FrontEnd/src/views/timeline/ConnectionStatusBadge.css36
-rw-r--r--FrontEnd/src/views/timeline/ConnectionStatusBadge.tsx41
-rw-r--r--FrontEnd/src/views/timeline/MarkdownPostEdit.css21
-rw-r--r--FrontEnd/src/views/timeline/MarkdownPostEdit.tsx209
-rw-r--r--FrontEnd/src/views/timeline/PostPropertyChangeDialog.tsx42
-rw-r--r--FrontEnd/src/views/timeline/Timeline.tsx166
-rw-r--r--FrontEnd/src/views/timeline/TimelineCard.tsx210
-rw-r--r--FrontEnd/src/views/timeline/TimelineDateLabel.tsx19
-rw-r--r--FrontEnd/src/views/timeline/TimelineEmptyItem.tsx25
-rw-r--r--FrontEnd/src/views/timeline/TimelineLine.tsx51
-rw-r--r--FrontEnd/src/views/timeline/TimelineLoading.tsx16
-rw-r--r--FrontEnd/src/views/timeline/TimelineMember.css8
-rw-r--r--FrontEnd/src/views/timeline/TimelineMember.tsx193
-rw-r--r--FrontEnd/src/views/timeline/TimelinePagedPostListView.tsx34
-rw-r--r--FrontEnd/src/views/timeline/TimelinePostContentView.tsx197
-rw-r--r--FrontEnd/src/views/timeline/TimelinePostEdit.css20
-rw-r--r--FrontEnd/src/views/timeline/TimelinePostEdit.tsx270
-rw-r--r--FrontEnd/src/views/timeline/TimelinePostEditCard.tsx31
-rw-r--r--FrontEnd/src/views/timeline/TimelinePostEditNoLogin.tsx18
-rw-r--r--FrontEnd/src/views/timeline/TimelinePostListView.tsx75
-rw-r--r--FrontEnd/src/views/timeline/TimelinePostView.tsx159
-rw-r--r--FrontEnd/src/views/timeline/TimelinePropertyChangeDialog.tsx88
-rw-r--r--FrontEnd/src/views/timeline/index.css246
-rw-r--r--FrontEnd/src/views/timeline/index.tsx80
25 files changed, 2221 insertions, 57 deletions
diff --git a/FrontEnd/src/views/timeline/CollapseButton.tsx b/FrontEnd/src/views/timeline/CollapseButton.tsx
new file mode 100644
index 00000000..31976228
--- /dev/null
+++ b/FrontEnd/src/views/timeline/CollapseButton.tsx
@@ -0,0 +1,23 @@
+import React from "react";
+import classnames from "classnames";
+
+const CollapseButton: React.FC<{
+ collapse: boolean;
+ onClick: () => void;
+ className?: string;
+ style?: React.CSSProperties;
+}> = ({ collapse, onClick, className, style }) => {
+ return (
+ <i
+ onClick={onClick}
+ className={classnames(
+ collapse ? "bi-arrows-angle-expand" : "bi-arrows-angle-contract",
+ "cru-color-primary icon-button",
+ className
+ )}
+ style={style}
+ />
+ );
+};
+
+export default CollapseButton;
diff --git a/FrontEnd/src/views/timeline/ConnectionStatusBadge.css b/FrontEnd/src/views/timeline/ConnectionStatusBadge.css
new file mode 100644
index 00000000..7fe83b9b
--- /dev/null
+++ b/FrontEnd/src/views/timeline/ConnectionStatusBadge.css
@@ -0,0 +1,36 @@
+.connection-status-badge {
+ font-size: 0.8em;
+ border-radius: 5px;
+ padding: 0.1em 1em;
+ background-color: #eaf2ff;
+}
+.connection-status-badge::before {
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ display: inline-block;
+ content: "";
+ margin-right: 0.6em;
+}
+.connection-status-badge.success {
+ color: #006100;
+}
+.connection-status-badge.success::before {
+ background-color: #006100;
+}
+
+.connection-status-badge.warning {
+ color: #e4a700;
+}
+
+.connection-status-badge.warning::before {
+ background-color: #e4a700;
+}
+
+.connection-status-badge.danger {
+ color: #fd1616;
+}
+
+.connection-status-badge.danger::before {
+ background-color: #fd1616;
+}
diff --git a/FrontEnd/src/views/timeline/ConnectionStatusBadge.tsx b/FrontEnd/src/views/timeline/ConnectionStatusBadge.tsx
new file mode 100644
index 00000000..c8478557
--- /dev/null
+++ b/FrontEnd/src/views/timeline/ConnectionStatusBadge.tsx
@@ -0,0 +1,41 @@
+import React from "react";
+import classnames from "classnames";
+import { HubConnectionState } from "@microsoft/signalr";
+import { useTranslation } from "react-i18next";
+
+import "./ConnectionStatusBadge.css";
+
+export interface ConnectionStatusBadgeProps {
+ status: HubConnectionState;
+ className?: string;
+ style?: React.CSSProperties;
+}
+
+const classNameMap: Record<HubConnectionState, string> = {
+ Connected: "success",
+ Connecting: "warning",
+ Disconnected: "danger",
+ Disconnecting: "warning",
+ Reconnecting: "warning",
+};
+
+const ConnectionStatusBadge: React.FC<ConnectionStatusBadgeProps> = (props) => {
+ const { status, className, style } = props;
+
+ const { t } = useTranslation();
+
+ return (
+ <div
+ className={classnames(
+ "connection-status-badge",
+ classNameMap[status],
+ className
+ )}
+ style={style}
+ >
+ {t(`connectionState.${status}`)}
+ </div>
+ );
+};
+
+export default ConnectionStatusBadge;
diff --git a/FrontEnd/src/views/timeline/MarkdownPostEdit.css b/FrontEnd/src/views/timeline/MarkdownPostEdit.css
new file mode 100644
index 00000000..e36be992
--- /dev/null
+++ b/FrontEnd/src/views/timeline/MarkdownPostEdit.css
@@ -0,0 +1,21 @@
+.timeline-markdown-post-edit-page {
+ overflow: auto;
+ max-height: 300px;
+}
+
+.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/views/timeline/MarkdownPostEdit.tsx b/FrontEnd/src/views/timeline/MarkdownPostEdit.tsx
new file mode 100644
index 00000000..35a2bbf5
--- /dev/null
+++ b/FrontEnd/src/views/timeline/MarkdownPostEdit.tsx
@@ -0,0 +1,209 @@
+import React from "react";
+import classnames from "classnames";
+import { useTranslation } from "react-i18next";
+
+import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline";
+
+import TimelinePostBuilder from "@/services/TimelinePostBuilder";
+
+import FlatButton from "../common/button/FlatButton";
+import TabPages from "../common/tab/TabPages";
+import ConfirmDialog from "../common/dailog/ConfirmDialog";
+import Spinner from "../common/Spinner";
+
+import "./MarkdownPostEdit.css";
+
+export interface MarkdownPostEditProps {
+ owner: string;
+ timeline: string;
+ onPosted: (post: HttpTimelinePostInfo) => void;
+ onPostError: () => void;
+ onClose: () => void;
+ className?: string;
+ style?: React.CSSProperties;
+}
+
+const MarkdownPostEdit: React.FC<MarkdownPostEditProps> = ({
+ owner: ownerUsername,
+ 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;
+ };
+
+ 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}
+ style={style}
+ pageContainerClassName="py-2"
+ dense
+ actions={
+ process ? (
+ <Spinner />
+ ) : (
+ <div>
+ <i
+ className="icon-button large bi-x cru-color-danger cru-align-middle me-2"
+ onClick={() => {
+ if (canLeave) {
+ onClose();
+ } else {
+ setShowLeaveConfirmDialog(true);
+ }
+ }}
+ />
+ {canSend && (
+ <FlatButton text="timeline.send" onClick={() => void send()} />
+ )}
+ </div>
+ )
+ }
+ pages={[
+ {
+ name: "text",
+ text: "edit",
+ page: (
+ <textarea
+ value={text}
+ disabled={process}
+ className="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"
+ />
+ <i
+ className={classnames(
+ "bi-trash text-danger icon-button 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 }}
+ />
+ ),
+ },
+ ]}
+ />
+ <ConfirmDialog
+ onClose={() => setShowLeaveConfirmDialog(false)}
+ onConfirm={onClose}
+ open={showLeaveConfirmDialog}
+ title="timeline.dropDraft"
+ body="timeline.confirmLeave"
+ />
+ </>
+ );
+};
+
+export default MarkdownPostEdit;
diff --git a/FrontEnd/src/views/timeline/PostPropertyChangeDialog.tsx b/FrontEnd/src/views/timeline/PostPropertyChangeDialog.tsx
new file mode 100644
index 00000000..d000093d
--- /dev/null
+++ b/FrontEnd/src/views/timeline/PostPropertyChangeDialog.tsx
@@ -0,0 +1,42 @@
+import React from "react";
+
+import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline";
+
+import OperationDialog from "../common/dailog/OperationDialog";
+
+function PostPropertyChangeDialog(props: {
+ open: boolean;
+ onClose: () => void;
+ post: HttpTimelinePostInfo;
+ onSuccess: (post: HttpTimelinePostInfo) => void;
+}): React.ReactElement | null {
+ const { open, onClose, post, onSuccess } = props;
+
+ return (
+ <OperationDialog
+ title="timeline.changePostPropertyDialog.title"
+ onClose={onClose}
+ open={open}
+ inputScheme={[
+ {
+ label: "timeline.changePostPropertyDialog.time",
+ type: "datetime",
+ initValue: post.time,
+ },
+ ]}
+ onProcess={([time]) => {
+ return getHttpTimelineClient().patchPost(
+ post.timelineOwnerV2,
+ post.timelineNameV2,
+ post.id,
+ {
+ time: time === "" ? undefined : new Date(time).toISOString(),
+ }
+ );
+ }}
+ onSuccessAndClose={onSuccess}
+ />
+ );
+}
+
+export default PostPropertyChangeDialog;
diff --git a/FrontEnd/src/views/timeline/Timeline.tsx b/FrontEnd/src/views/timeline/Timeline.tsx
new file mode 100644
index 00000000..4738c705
--- /dev/null
+++ b/FrontEnd/src/views/timeline/Timeline.tsx
@@ -0,0 +1,166 @@
+import React from "react";
+import { HubConnectionState } from "@microsoft/signalr";
+import classnames from "classnames";
+
+import {
+ HttpForbiddenError,
+ HttpNetworkError,
+ HttpNotFoundError,
+} from "@/http/common";
+import {
+ getHttpTimelineClient,
+ HttpTimelineInfo,
+ HttpTimelinePostInfo,
+} from "@/http/timeline";
+
+import { getTimelinePostUpdate$ } from "@/services/timeline";
+import { useUser } from "@/services/user";
+
+import useValueWithRef from "@/utilities/useValueWithRef";
+
+import TimelinePagedPostListView from "./TimelinePagedPostListView";
+import TimelineEmptyItem from "./TimelineEmptyItem";
+import TimelineLoading from "./TimelineLoading";
+import TimelinePostEdit from "./TimelinePostEdit";
+import TimelinePostEditNoLogin from "./TimelinePostEditNoLogin";
+
+import "./index.css";
+
+export interface TimelineProps {
+ className?: string;
+ style?: React.CSSProperties;
+ timelineOwner: string;
+ timelineName: string;
+ reloadKey: number;
+ onReload: () => void;
+ onTimelineLoaded?: (timeline: HttpTimelineInfo) => void;
+ onConnectionStateChanged?: (state: HubConnectionState) => void;
+}
+
+const Timeline: React.FC<TimelineProps> = (props) => {
+ const { timelineOwner, timelineName, className, style, reloadKey } = props;
+
+ const user = useUser();
+
+ const [state, setState] = React.useState<
+ "loading" | "loaded" | "offline" | "notexist" | "forbid" | "error"
+ >("loading");
+ const [timeline, setTimeline] = React.useState<HttpTimelineInfo | null>(null);
+ const [posts, setPosts] = React.useState<HttpTimelinePostInfo[]>([]);
+
+ React.useEffect(() => {
+ setState("loading");
+ setTimeline(null);
+ setPosts([]);
+ }, [timelineName]);
+
+ const onReload = useValueWithRef(props.onReload);
+ const onTimelineLoaded = useValueWithRef(props.onTimelineLoaded);
+ const onConnectionStateChanged = useValueWithRef(
+ props.onConnectionStateChanged
+ );
+
+ React.useEffect(() => {
+ if (timelineName != null && state === "loaded") {
+ const timelinePostUpdate$ = getTimelinePostUpdate$(timelineName);
+ const subscription = timelinePostUpdate$.subscribe(
+ ({ update, state }) => {
+ if (update) {
+ onReload.current();
+ }
+ onConnectionStateChanged.current?.(state);
+ }
+ );
+ return () => {
+ subscription.unsubscribe();
+ };
+ }
+ }, [timelineName, state, onReload, onConnectionStateChanged]);
+
+ React.useEffect(() => {
+ if (timelineName != null) {
+ let subscribe = true;
+
+ const client = getHttpTimelineClient();
+ Promise.all([
+ client.getTimeline(timelineOwner, timelineName),
+ client.listPost(timelineOwner, timelineName),
+ ]).then(
+ ([t, p]) => {
+ if (subscribe) {
+ setTimeline(t);
+ setPosts(p);
+ setState("loaded");
+ onTimelineLoaded.current?.(t);
+ }
+ },
+ (error) => {
+ if (subscribe) {
+ if (error instanceof HttpNetworkError) {
+ setState("offline");
+ } else if (error instanceof HttpForbiddenError) {
+ setState("forbid");
+ } else if (error instanceof HttpNotFoundError) {
+ setState("notexist");
+ } else {
+ console.error(error);
+ setState("error");
+ }
+ }
+ }
+ );
+
+ return () => {
+ subscribe = false;
+ };
+ }
+ }, [timelineName, reloadKey, onTimelineLoaded]);
+
+ switch (state) {
+ case "loading":
+ return <TimelineLoading />;
+ case "offline":
+ return (
+ <div className={className} style={style}>
+ Offline.
+ </div>
+ );
+ case "notexist":
+ return (
+ <div className={className} style={style}>
+ Not exist.
+ </div>
+ );
+ case "forbid":
+ return (
+ <div className={className} style={style}>
+ Forbid.
+ </div>
+ );
+ case "error":
+ return (
+ <div className={className} style={style}>
+ Error.
+ </div>
+ );
+ default:
+ return (
+ <div style={style} className={classnames("timeline", className)}>
+ <TimelineEmptyItem height={40} />
+ <TimelinePagedPostListView
+ posts={posts}
+ onReload={onReload.current}
+ />
+ {timeline?.postable ? (
+ <TimelinePostEdit timeline={timeline} onPosted={onReload.current} />
+ ) : user == null ? (
+ <TimelinePostEditNoLogin />
+ ) : (
+ <TimelineEmptyItem startSegmentLength={20} center="none" current />
+ )}
+ </div>
+ );
+ }
+};
+
+export default Timeline;
diff --git a/FrontEnd/src/views/timeline/TimelineCard.tsx b/FrontEnd/src/views/timeline/TimelineCard.tsx
index 339fbfa0..156c581e 100644
--- a/FrontEnd/src/views/timeline/TimelineCard.tsx
+++ b/FrontEnd/src/views/timeline/TimelineCard.tsx
@@ -1,62 +1,182 @@
import React from "react";
+import { useTranslation } from "react-i18next";
+import classnames from "classnames";
+import { HubConnectionState } from "@microsoft/signalr";
-import { TimelinePageCardProps } from "../timeline-common/TimelinePageTemplate";
-import TimelinePageCardTemplate from "../timeline-common/TimelinePageCardTemplate";
+import { useIsSmallScreen } from "@/utilities/mediaQuery";
+import { timelineVisibilityTooltipTranslationMap } from "@/services/timeline";
+import { useUser } from "@/services/user";
+import { pushAlert } from "@/services/alert";
+import { HttpTimelineInfo } from "@/http/timeline";
+import { getHttpHighlightClient } from "@/http/highlight";
+import { getHttpBookmarkClient } from "@/http/bookmark";
import UserAvatar from "../common/user/UserAvatar";
+import PopupMenu from "../common/menu/PopupMenu";
+import FullPageDialog from "../common/dailog/FullPageDialog";
+import Card from "../common/Card";
import TimelineDeleteDialog from "./TimelineDeleteDialog";
+import ConnectionStatusBadge from "./ConnectionStatusBadge";
+import CollapseButton from "./CollapseButton";
+import { TimelineMemberDialog } from "./TimelineMember";
+import TimelinePropertyChangeDialog from "./TimelinePropertyChangeDialog";
+
+export interface TimelinePageCardProps {
+ timeline: HttpTimelineInfo;
+ collapse: boolean;
+ toggleCollapse: () => void;
+ connectionStatus: HubConnectionState;
+ className?: string;
+ onReload: () => void;
+}
const TimelineCard: React.FC<TimelinePageCardProps> = (props) => {
- const { timeline } = props;
+ const {
+ timeline,
+ collapse,
+ toggleCollapse,
+ connectionStatus,
+ onReload,
+ className,
+ } = props;
+
+ const { t } = useTranslation();
const [dialog, setDialog] = React.useState<
"member" | "property" | "delete" | null
>(null);
+ const isSmallScreen = useIsSmallScreen();
+
+ const user = useUser();
+
+ const content = (
+ <>
+ <h3 className="cru-color-primary d-inline-block align-middle">
+ {timeline.title}
+ <small className="ms-3 cru-color-secondary">{timeline.name}</small>
+ </h3>
+ <div>
+ <UserAvatar
+ username={timeline.owner.username}
+ className="cru-avatar small cru-round me-3"
+ />
+ {timeline.owner.nickname}
+ <small className="ms-3 cru-color-secondary">
+ @{timeline.owner.username}
+ </small>
+ </div>
+ <p className="mb-0">{timeline.description}</p>
+ <small className="mt-1 d-block">
+ {t(timelineVisibilityTooltipTranslationMap[timeline.visibility])}
+ </small>
+ <div className="mt-2 cru-text-end">
+ <i
+ className={classnames(
+ timeline.isHighlight ? "bi-star-fill" : "bi-star",
+ "icon-button cru-color-primary me-3"
+ )}
+ onClick={
+ user?.hasHighlightTimelineAdministrationPermission
+ ? () => {
+ getHttpHighlightClient()
+ [timeline.isHighlight ? "delete" : "put"](timeline.name)
+ .then(onReload, () => {
+ pushAlert({
+ message: timeline.isHighlight
+ ? "timeline.removeHighlightFail"
+ : "timeline.addHighlightFail",
+ type: "danger",
+ });
+ });
+ }
+ : undefined
+ }
+ />
+ {user != null ? (
+ <i
+ className={classnames(
+ timeline.isBookmark ? "bi-bookmark-fill" : "bi-bookmark",
+ "icon-button cru-color-primary me-3"
+ )}
+ onClick={() => {
+ getHttpBookmarkClient()
+ [timeline.isBookmark ? "delete" : "put"](timeline.name)
+ .then(onReload, () => {
+ pushAlert({
+ message: timeline.isBookmark
+ ? "timeline.removeBookmarkFail"
+ : "timeline.addBookmarkFail",
+ type: "danger",
+ });
+ });
+ }}
+ />
+ ) : null}
+ <i
+ className={"icon-button bi-people cru-color-primary me-3"}
+ onClick={() => setDialog("member")}
+ />
+ {timeline.manageable ? (
+ <PopupMenu
+ items={[
+ {
+ type: "button",
+ text: "timeline.manageItem.property",
+ onClick: () => setDialog("property"),
+ },
+ { type: "divider" },
+ {
+ type: "button",
+ onClick: () => setDialog("delete"),
+ color: "danger",
+ text: "timeline.manageItem.delete",
+ },
+ ]}
+ containerClassName="d-inline"
+ >
+ <i className="icon-button bi-three-dots-vertical cru-color-primary" />
+ </PopupMenu>
+ ) : null}
+ </div>
+ </>
+ );
+
return (
<>
- <TimelinePageCardTemplate
- infoArea={
- <>
- <h3 className="cru-color-primary d-inline-block align-middle">
- {timeline.title}
- <small className="ms-3 cru-color-secondary">
- {timeline.name}
- </small>
- </h3>
- <div>
- <UserAvatar
- username={timeline.owner.username}
- className="cru-avatar small cru-round me-3"
- />
- {timeline.owner.nickname}
- <small className="ms-3 cru-color-secondary">
- @{timeline.owner.username}
- </small>
- </div>
- </>
- }
- manageItems={
- timeline.manageable
- ? [
- {
- type: "button",
- text: "timeline.manageItem.property",
- onClick: () => setDialog("property"),
- },
- { type: "divider" },
- {
- type: "button",
- onClick: () => setDialog("delete"),
- color: "danger",
- text: "timeline.manageItem.delete",
- },
- ]
- : undefined
- }
- dialog={dialog}
- setDialog={setDialog}
- {...props}
+ <Card className={classnames("p-2 cru-clearfix", className)}>
+ <div
+ className={classnames(
+ "cru-float-right d-flex align-items-center",
+ !collapse && "ms-3"
+ )}
+ >
+ <ConnectionStatusBadge status={connectionStatus} className="me-2" />
+ <CollapseButton collapse={collapse} onClick={toggleCollapse} />
+ </div>
+ {isSmallScreen ? (
+ <FullPageDialog
+ onBack={toggleCollapse}
+ show={!collapse}
+ contentContainerClassName="p-2"
+ >
+ {content}
+ </FullPageDialog>
+ ) : (
+ <div style={{ display: collapse ? "none" : "inline" }}>{content}</div>
+ )}
+ </Card>
+ <TimelineMemberDialog
+ timeline={timeline}
+ onClose={() => setDialog(null)}
+ open={dialog === "member"}
+ onChange={onReload}
+ />
+ <TimelinePropertyChangeDialog
+ timeline={timeline}
+ close={() => setDialog(null)}
+ open={dialog === "property"}
+ onChange={onReload}
/>
<TimelineDeleteDialog
timeline={timeline}
diff --git a/FrontEnd/src/views/timeline/TimelineDateLabel.tsx b/FrontEnd/src/views/timeline/TimelineDateLabel.tsx
new file mode 100644
index 00000000..80968ee2
--- /dev/null
+++ b/FrontEnd/src/views/timeline/TimelineDateLabel.tsx
@@ -0,0 +1,19 @@
+import React from "react";
+import TimelineLine from "./TimelineLine";
+
+export interface TimelineDateItemProps {
+ date: Date;
+}
+
+const TimelineDateLabel: React.FC<TimelineDateItemProps> = ({ date }) => {
+ return (
+ <div className="timeline-date-item">
+ <TimelineLine center="none" />
+ <div className="timeline-date-item-badge">
+ {date.toLocaleDateString()}
+ </div>
+ </div>
+ );
+};
+
+export default TimelineDateLabel;
diff --git a/FrontEnd/src/views/timeline/TimelineEmptyItem.tsx b/FrontEnd/src/views/timeline/TimelineEmptyItem.tsx
new file mode 100644
index 00000000..8638ad46
--- /dev/null
+++ b/FrontEnd/src/views/timeline/TimelineEmptyItem.tsx
@@ -0,0 +1,25 @@
+import React from "react";
+import classnames from "classnames";
+
+import TimelineLine, { TimelineLineProps } from "./TimelineLine";
+
+export interface TimelineEmptyItemProps extends Partial<TimelineLineProps> {
+ height?: number | string;
+ className?: string;
+ style?: React.CSSProperties;
+}
+
+const TimelineEmptyItem: React.FC<TimelineEmptyItemProps> = (props) => {
+ const { height, style, className, center, ...lineProps } = props;
+
+ return (
+ <div
+ style={{ ...style, height: height }}
+ className={classnames("timeline-item", className)}
+ >
+ <TimelineLine center={center ?? "none"} {...lineProps} />
+ </div>
+ );
+};
+
+export default TimelineEmptyItem;
diff --git a/FrontEnd/src/views/timeline/TimelineLine.tsx b/FrontEnd/src/views/timeline/TimelineLine.tsx
new file mode 100644
index 00000000..0a828b32
--- /dev/null
+++ b/FrontEnd/src/views/timeline/TimelineLine.tsx
@@ -0,0 +1,51 @@
+import React from "react";
+import classnames from "classnames";
+
+export interface TimelineLineProps {
+ current?: boolean;
+ startSegmentLength?: string | number;
+ center: "node" | "loading" | "none";
+ className?: string;
+ style?: React.CSSProperties;
+}
+
+const TimelineLine: React.FC<TimelineLineProps> = ({
+ startSegmentLength,
+ center,
+ current,
+ className,
+ style,
+}) => {
+ return (
+ <div
+ className={classnames(
+ "timeline-line",
+ current && "current",
+ center === "loading" && "loading",
+ className
+ )}
+ style={style}
+ >
+ <div className="segment start" style={{ height: startSegmentLength }} />
+ {center !== "none" ? (
+ <div className="node-container">
+ <div className="node"></div>
+ {center === "loading" ? (
+ <svg className="node-loading-edge" viewBox="0 0 100 100">
+ <path
+ d="M 50,10 A 40 40 45 0 1 78.28,21.72"
+ stroke="currentcolor"
+ strokeLinecap="square"
+ strokeWidth="8"
+ />
+ </svg>
+ ) : null}
+ </div>
+ ) : null}
+ {center !== "loading" ? <div className="segment end"></div> : null}
+ {current && <div className="segment current-end" />}
+ </div>
+ );
+};
+
+export default TimelineLine;
diff --git a/FrontEnd/src/views/timeline/TimelineLoading.tsx b/FrontEnd/src/views/timeline/TimelineLoading.tsx
new file mode 100644
index 00000000..f55482fe
--- /dev/null
+++ b/FrontEnd/src/views/timeline/TimelineLoading.tsx
@@ -0,0 +1,16 @@
+import React from "react";
+
+import TimelineEmptyItem from "./TimelineEmptyItem";
+
+const TimelineLoading: React.FC = () => {
+ return (
+ <TimelineEmptyItem
+ className="timeline-top-loading-enter"
+ height={100}
+ center="loading"
+ startSegmentLength={56}
+ />
+ );
+};
+
+export default TimelineLoading;
diff --git a/FrontEnd/src/views/timeline/TimelineMember.css b/FrontEnd/src/views/timeline/TimelineMember.css
new file mode 100644
index 00000000..adb78764
--- /dev/null
+++ b/FrontEnd/src/views/timeline/TimelineMember.css
@@ -0,0 +1,8 @@
+.timeline-member-item {
+ border: var(--cru-background-1-color) solid;
+ border-width: 0.5px 1px;
+}
+
+.timeline-member-item > div {
+ padding: 0.5em;
+}
diff --git a/FrontEnd/src/views/timeline/TimelineMember.tsx b/FrontEnd/src/views/timeline/TimelineMember.tsx
new file mode 100644
index 00000000..59d4c371
--- /dev/null
+++ b/FrontEnd/src/views/timeline/TimelineMember.tsx
@@ -0,0 +1,193 @@
+import React, { useState } from "react";
+import { useTranslation } from "react-i18next";
+
+import { convertI18nText, I18nText } from "@/common";
+
+import { HttpUser } from "@/http/user";
+import { getHttpSearchClient } from "@/http/search";
+import { getHttpTimelineClient, HttpTimelineInfo } from "@/http/timeline";
+
+import SearchInput from "../common/SearchInput";
+import UserAvatar from "../common/user/UserAvatar";
+import Button from "../common/button/Button";
+import Dialog from "../common/dailog/Dialog";
+
+import "./TimelineMember.css";
+
+const TimelineMemberItem: React.FC<{
+ user: HttpUser;
+ add?: boolean;
+ onAction?: (username: string) => void;
+}> = ({ user, add, onAction }) => {
+ return (
+ <div className="container timeline-member-item">
+ <div className="row">
+ <div className="col col-auto">
+ <UserAvatar username={user.username} className="cru-avatar small" />
+ </div>
+ <div className="col">
+ <div className="row">{user.nickname}</div>
+ <small className="row">{"@" + user.username}</small>
+ </div>
+ {onAction ? (
+ <div className="col col-auto">
+ <Button
+ text={`timeline.member.${add ? "add" : "remove"}`}
+ color={add ? "success" : "danger"}
+ onClick={() => {
+ onAction(user.username);
+ }}
+ />
+ </div>
+ ) : null}
+ </div>
+ </div>
+ );
+};
+
+const TimelineMemberUserSearch: React.FC<{
+ timeline: HttpTimelineInfo;
+ onChange: () => void;
+}> = ({ timeline, onChange }) => {
+ const { t } = useTranslation();
+
+ const [userSearchText, setUserSearchText] = useState<string>("");
+ const [userSearchState, setUserSearchState] = useState<
+ | {
+ type: "users";
+ data: HttpUser[];
+ }
+ | { type: "error"; data: I18nText }
+ | { type: "loading" }
+ | { type: "init" }
+ >({ type: "init" });
+
+ return (
+ <>
+ <SearchInput
+ className="mt-3"
+ value={userSearchText}
+ onChange={(v) => {
+ setUserSearchText(v);
+ }}
+ loading={userSearchState.type === "loading"}
+ onButtonClick={() => {
+ if (userSearchText === "") {
+ setUserSearchState({
+ type: "error",
+ data: "login.emptyUsername",
+ });
+ return;
+ }
+ setUserSearchState({ type: "loading" });
+ getHttpSearchClient()
+ .searchUsers(userSearchText)
+ .then(
+ (users) => {
+ users = users.filter(
+ (user) =>
+ timeline.members.findIndex(
+ (m) => m.username === user.username
+ ) === -1 && timeline.owner.username !== user.username
+ );
+ setUserSearchState({ type: "users", data: users });
+ },
+ (e) => {
+ setUserSearchState({
+ type: "error",
+ data: { type: "custom", value: String(e) },
+ });
+ }
+ );
+ }}
+ />
+ {(() => {
+ if (userSearchState.type === "users") {
+ const users = userSearchState.data;
+ if (users.length === 0) {
+ return <div>{t("timeline.member.noUserAvailableToAdd")}</div>;
+ } else {
+ return (
+ <div className="mt-2">
+ {users.map((user) => (
+ <TimelineMemberItem
+ key={user.username}
+ user={user}
+ add
+ onAction={() => {
+ void getHttpTimelineClient()
+ .memberPut(timeline.name, user.username)
+ .then(() => {
+ setUserSearchText("");
+ setUserSearchState({ type: "init" });
+ onChange();
+ });
+ }}
+ />
+ ))}
+ </div>
+ );
+ }
+ } else if (userSearchState.type === "error") {
+ return (
+ <div className="cru-color-danger">
+ {convertI18nText(userSearchState.data, t)}
+ </div>
+ );
+ }
+ })()}
+ </>
+ );
+};
+
+export interface TimelineMemberProps {
+ timeline: HttpTimelineInfo;
+ onChange: () => void;
+}
+
+const TimelineMember: React.FC<TimelineMemberProps> = (props) => {
+ const { timeline, onChange } = props;
+ const members = [timeline.owner, ...timeline.members];
+
+ return (
+ <div className="container px-4 py-3">
+ <div>
+ {members.map((member, index) => (
+ <TimelineMemberItem
+ key={member.username}
+ user={member}
+ onAction={
+ timeline.manageable && index !== 0
+ ? () => {
+ void getHttpTimelineClient()
+ .memberDelete(timeline.name, member.username)
+ .then(onChange);
+ }
+ : undefined
+ }
+ />
+ ))}
+ </div>
+ {timeline.manageable ? (
+ <TimelineMemberUserSearch timeline={timeline} onChange={onChange} />
+ ) : null}
+ </div>
+ );
+};
+
+export default TimelineMember;
+
+export interface TimelineMemberDialogProps extends TimelineMemberProps {
+ open: boolean;
+ onClose: () => void;
+}
+
+export const TimelineMemberDialog: React.FC<TimelineMemberDialogProps> = (
+ props
+) => {
+ return (
+ <Dialog open={props.open} onClose={props.onClose}>
+ <TimelineMember {...props} />
+ </Dialog>
+ );
+};
diff --git a/FrontEnd/src/views/timeline/TimelinePagedPostListView.tsx b/FrontEnd/src/views/timeline/TimelinePagedPostListView.tsx
new file mode 100644
index 00000000..69a5607c
--- /dev/null
+++ b/FrontEnd/src/views/timeline/TimelinePagedPostListView.tsx
@@ -0,0 +1,34 @@
+import React from "react";
+
+import { HttpTimelinePostInfo } from "@/http/timeline";
+
+import useScrollToTop from "@/utilities/useScrollToTop";
+
+import TimelinePostListView from "./TimelinePostListView";
+
+export interface TimelinePagedPostListViewProps {
+ posts: HttpTimelinePostInfo[];
+ onReload: () => void;
+}
+
+const TimelinePagedPostListView: React.FC<TimelinePagedPostListViewProps> = (
+ props
+) => {
+ const { posts, onReload } = props;
+
+ const [lastViewCount, setLastViewCount] = React.useState<number>(10);
+
+ const viewingPosts = React.useMemo(() => {
+ return lastViewCount >= posts.length
+ ? posts.slice()
+ : posts.slice(-lastViewCount);
+ }, [posts, lastViewCount]);
+
+ useScrollToTop(() => {
+ setLastViewCount(lastViewCount + 10);
+ }, lastViewCount < posts.length);
+
+ return <TimelinePostListView posts={viewingPosts} onReload={onReload} />;
+};
+
+export default TimelinePagedPostListView;
diff --git a/FrontEnd/src/views/timeline/TimelinePostContentView.tsx b/FrontEnd/src/views/timeline/TimelinePostContentView.tsx
new file mode 100644
index 00000000..607b72c9
--- /dev/null
+++ b/FrontEnd/src/views/timeline/TimelinePostContentView.tsx
@@ -0,0 +1,197 @@
+import React from "react";
+import classnames from "classnames";
+import { Remarkable } from "remarkable";
+
+import { UiLogicError } from "@/common";
+
+import { HttpNetworkError } from "@/http/common";
+import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline";
+
+import { useUser } from "@/services/user";
+
+import Skeleton from "../common/Skeleton";
+import LoadFailReload from "../common/LoadFailReload";
+
+const TextView: React.FC<TimelinePostContentViewProps> = (props) => {
+ const { post, className, style } = props;
+
+ const [text, setText] = React.useState<string | null>(null);
+ const [error, setError] = React.useState<"offline" | "error" | null>(null);
+
+ const [reloadKey, setReloadKey] = React.useState<number>(0);
+
+ React.useEffect(() => {
+ let subscribe = true;
+
+ setText(null);
+ setError(null);
+
+ void getHttpTimelineClient()
+ .getPostDataAsString(post.timelineName, post.id)
+ .then(
+ (data) => {
+ if (subscribe) setText(data);
+ },
+ (error) => {
+ if (subscribe) {
+ if (error instanceof HttpNetworkError) {
+ setError("offline");
+ } else {
+ setError("error");
+ }
+ }
+ }
+ );
+
+ return () => {
+ subscribe = false;
+ };
+ }, [post.timelineName, post.id, reloadKey]);
+
+ if (error != null) {
+ return (
+ <LoadFailReload
+ className={className}
+ style={style}
+ onReload={() => setReloadKey(reloadKey + 1)}
+ />
+ );
+ } else if (text == null) {
+ return <Skeleton />;
+ } else {
+ return (
+ <div className={className} style={style}>
+ {text}
+ </div>
+ );
+ }
+};
+
+const ImageView: React.FC<TimelinePostContentViewProps> = (props) => {
+ const { post, className, style } = props;
+
+ useUser();
+
+ return (
+ <img
+ src={getHttpTimelineClient().generatePostDataUrl(
+ post.timelineName,
+ post.id
+ )}
+ className={classnames(className, "timeline-content-image")}
+ style={style}
+ />
+ );
+};
+
+const MarkdownView: React.FC<TimelinePostContentViewProps> = (props) => {
+ const { post, className, style } = props;
+
+ const _remarkable = React.useRef<Remarkable>();
+
+ const getRemarkable = (): Remarkable => {
+ if (_remarkable.current) {
+ return _remarkable.current;
+ } else {
+ _remarkable.current = new Remarkable();
+ return _remarkable.current;
+ }
+ };
+
+ const [markdown, setMarkdown] = React.useState<string | null>(null);
+ const [error, setError] = React.useState<"offline" | "error" | null>(null);
+
+ const [reloadKey, setReloadKey] = React.useState<number>(0);
+
+ React.useEffect(() => {
+ let subscribe = true;
+
+ setMarkdown(null);
+ setError(null);
+
+ void getHttpTimelineClient()
+ .getPostDataAsString(post.timelineName, post.id)
+ .then(
+ (data) => {
+ if (subscribe) setMarkdown(data);
+ },
+ (error) => {
+ if (subscribe) {
+ if (error instanceof HttpNetworkError) {
+ setError("offline");
+ } else {
+ setError("error");
+ }
+ }
+ }
+ );
+
+ return () => {
+ subscribe = false;
+ };
+ }, [post.timelineName, post.id, reloadKey]);
+
+ const markdownHtml = React.useMemo<string | null>(() => {
+ if (markdown == null) return null;
+ return getRemarkable().render(markdown);
+ }, [markdown]);
+
+ if (error != null) {
+ return (
+ <LoadFailReload
+ className={className}
+ style={style}
+ onReload={() => setReloadKey(reloadKey + 1)}
+ />
+ );
+ } else if (markdown == null) {
+ return <Skeleton />;
+ } else {
+ if (markdownHtml == null) {
+ throw new UiLogicError("Markdown is not null but markdown html is.");
+ }
+ return (
+ <div
+ className={classnames(className, "markdown-container")}
+ style={style}
+ dangerouslySetInnerHTML={{
+ __html: markdownHtml,
+ }}
+ />
+ );
+ }
+};
+
+export interface TimelinePostContentViewProps {
+ post: HttpTimelinePostInfo;
+ className?: string;
+ style?: React.CSSProperties;
+}
+
+const viewMap: Record<string, React.FC<TimelinePostContentViewProps>> = {
+ "text/plain": TextView,
+ "text/markdown": MarkdownView,
+ "image/png": ImageView,
+ "image/jpeg": ImageView,
+ "image/gif": ImageView,
+ "image/webp": ImageView,
+};
+
+const TimelinePostContentView: React.FC<TimelinePostContentViewProps> = (
+ props
+) => {
+ const { post, className, style } = props;
+
+ const type = post.dataList[0].kind;
+
+ if (type in viewMap) {
+ const View = viewMap[type];
+ return <View post={post} className={className} style={style} />;
+ } else {
+ // TODO: i18n
+ console.error("Unknown post type", post);
+ return <div>Error, unknown post type!</div>;
+ }
+};
+
+export default TimelinePostContentView;
diff --git a/FrontEnd/src/views/timeline/TimelinePostEdit.css b/FrontEnd/src/views/timeline/TimelinePostEdit.css
new file mode 100644
index 00000000..4ce98383
--- /dev/null
+++ b/FrontEnd/src/views/timeline/TimelinePostEdit.css
@@ -0,0 +1,20 @@
+.timeline-item.timeline-post-edit {
+ padding-bottom: 0;
+}
+
+.timeline-post-edit .timeline-item-card {
+ border-bottom-left-radius: 0;
+ border-bottom-right-radius: 0;
+ border-bottom: none;
+}
+
+.timeline-post-edit {
+ position: sticky !important;
+ bottom: 0;
+ z-index: 1;
+}
+
+.timeline-post-edit-image {
+ max-width: 100px;
+ max-height: 100px;
+}
diff --git a/FrontEnd/src/views/timeline/TimelinePostEdit.tsx b/FrontEnd/src/views/timeline/TimelinePostEdit.tsx
new file mode 100644
index 00000000..cd61b4a7
--- /dev/null
+++ b/FrontEnd/src/views/timeline/TimelinePostEdit.tsx
@@ -0,0 +1,270 @@
+import React from "react";
+import classnames from "classnames";
+import { useTranslation } from "react-i18next";
+
+import { UiLogicError } from "@/common";
+
+import {
+ getHttpTimelineClient,
+ HttpTimelineInfo,
+ HttpTimelinePostInfo,
+ HttpTimelinePostPostRequestData,
+} from "@/http/timeline";
+
+import { pushAlert } from "@/services/alert";
+import { base64 } from "@/http/common";
+
+import BlobImage from "../common/BlobImage";
+import LoadingButton from "../common/button/LoadingButton";
+import PopupMenu from "../common/menu/PopupMenu";
+import MarkdownPostEdit from "./MarkdownPostEdit";
+import TimelinePostEditCard from "./TimelinePostEditCard";
+
+import "./TimelinePostEdit.css";
+
+interface TimelinePostEditTextProps {
+ text: string;
+ disabled: boolean;
+ onChange: (text: string) => void;
+ className?: string;
+ style?: React.CSSProperties;
+}
+
+const TimelinePostEditText: React.FC<TimelinePostEditTextProps> = (props) => {
+ const { text, disabled, onChange, className, style } = props;
+
+ return (
+ <textarea
+ value={text}
+ disabled={disabled}
+ onChange={(event) => {
+ onChange(event.target.value);
+ }}
+ className={className}
+ style={style}
+ />
+ );
+};
+
+interface TimelinePostEditImageProps {
+ onSelect: (file: File | null) => void;
+ disabled: boolean;
+}
+
+const TimelinePostEditImage: React.FC<TimelinePostEditImageProps> = (props) => {
+ const { onSelect, disabled } = props;
+
+ const { t } = useTranslation();
+
+ const [file, setFile] = React.useState<File | null>(null);
+ const [error, setError] = React.useState<boolean>(false);
+
+ const onInputChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
+ setError(false);
+ const files = e.target.files;
+ if (files == null || files.length === 0) {
+ setFile(null);
+ onSelect(null);
+ } else {
+ setFile(files[0]);
+ }
+ };
+
+ React.useEffect(() => {
+ return () => {
+ onSelect(null);
+ };
+ }, [onSelect]);
+
+ return (
+ <>
+ <input
+ type="file"
+ onChange={onInputChange}
+ accept="image/*"
+ disabled={disabled}
+ className="mx-3 my-1"
+ />
+ {file != null && !error && (
+ <BlobImage
+ blob={file}
+ className="timeline-post-edit-image"
+ onLoad={() => onSelect(file)}
+ onError={() => {
+ onSelect(null);
+ setError(true);
+ }}
+ />
+ )}
+ {error ? <div className="text-danger">{t("loadImageError")}</div> : null}
+ </>
+ );
+};
+
+type PostKind = "text" | "markdown" | "image";
+
+const postKindIconClassNameMap: Record<PostKind, string> = {
+ text: "bi-fonts",
+ markdown: "bi-markdown",
+ image: "bi-image",
+};
+
+export interface TimelinePostEditProps {
+ className?: string;
+ style?: React.CSSProperties;
+ timeline: HttpTimelineInfo;
+ onPosted: (newPost: HttpTimelinePostInfo) => void;
+}
+
+const TimelinePostEdit: React.FC<TimelinePostEditProps> = (props) => {
+ const { timeline, style, className, onPosted } = props;
+
+ const { t } = useTranslation();
+
+ const [process, setProcess] = React.useState<boolean>(false);
+
+ const [kind, setKind] = React.useState<Exclude<PostKind, "markdown">>("text");
+ const [showMarkdown, setShowMarkdown] = React.useState<boolean>(false);
+
+ const [text, setText] = React.useState<string>("");
+ const [image, setImage] = React.useState<File | null>(null);
+
+ const draftTextLocalStorageKey = `timeline.${timeline.name}.postDraft.text`;
+
+ React.useEffect(() => {
+ setText(window.localStorage.getItem(draftTextLocalStorageKey) ?? "");
+ }, [draftTextLocalStorageKey]);
+
+ const canSend =
+ (kind === "text" && text.length !== 0) ||
+ (kind === "image" && image != null);
+
+ const onPostError = (): void => {
+ pushAlert({
+ type: "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.name, {
+ dataList: [requestData],
+ })
+ .then(
+ (data) => {
+ if (kind === "text") {
+ setText("");
+ window.localStorage.removeItem(draftTextLocalStorageKey);
+ }
+ setProcess(false);
+ setKind("text");
+ onPosted(data);
+ },
+ (_) => {
+ setProcess(false);
+ onPostError();
+ }
+ );
+ };
+
+ return (
+ <TimelinePostEditCard className={className} style={style}>
+ {showMarkdown ? (
+ <MarkdownPostEdit
+ className="cru-fill-parent"
+ onClose={() => setShowMarkdown(false)}
+ timeline={timeline.name}
+ onPosted={onPosted}
+ onPostError={onPostError}
+ />
+ ) : (
+ <div className="row">
+ <div className="col px-1 py-1">
+ {(() => {
+ if (kind === "text") {
+ return (
+ <TimelinePostEditText
+ className="cru-fill-parent 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}
+ />
+ );
+ }
+ })()}
+ </div>
+ <div className="col col-auto align-self-end m-1">
+ <div className="d-block cru-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={classnames(
+ postKindIconClassNameMap[kind],
+ "icon-button large"
+ )}
+ />
+ </PopupMenu>
+ </div>
+ <LoadingButton
+ onClick={() => void onSend()}
+ disabled={!canSend}
+ loading={process}
+ >
+ {t("timeline.send")}
+ </LoadingButton>
+ </div>
+ </div>
+ )}
+ </TimelinePostEditCard>
+ );
+};
+
+export default TimelinePostEdit;
diff --git a/FrontEnd/src/views/timeline/TimelinePostEditCard.tsx b/FrontEnd/src/views/timeline/TimelinePostEditCard.tsx
new file mode 100644
index 00000000..a69d413a
--- /dev/null
+++ b/FrontEnd/src/views/timeline/TimelinePostEditCard.tsx
@@ -0,0 +1,31 @@
+import React from "react";
+import classnames from "classnames";
+
+import Card from "../common/Card";
+import TimelineLine from "./TimelineLine";
+
+import "./TimelinePostEdit.css";
+
+export interface TimelinePostEditCardProps {
+ className?: string;
+ style?: React.CSSProperties;
+ children?: React.ReactNode;
+}
+
+const TimelinePostEdit: React.FC<TimelinePostEditCardProps> = ({
+ className,
+ style,
+ children,
+}) => {
+ return (
+ <div
+ className={classnames("timeline-item timeline-post-edit", className)}
+ style={style}
+ >
+ <TimelineLine center="node" current />
+ <Card className="timeline-item-card">{children}</Card>
+ </div>
+ );
+};
+
+export default TimelinePostEdit;
diff --git a/FrontEnd/src/views/timeline/TimelinePostEditNoLogin.tsx b/FrontEnd/src/views/timeline/TimelinePostEditNoLogin.tsx
new file mode 100644
index 00000000..82834e95
--- /dev/null
+++ b/FrontEnd/src/views/timeline/TimelinePostEditNoLogin.tsx
@@ -0,0 +1,18 @@
+import React from "react";
+import { Trans } from "react-i18next";
+import { Link } from "react-router-dom";
+
+import TimelinePostEditCard from "./TimelinePostEditCard";
+
+export default function TimelinePostEditNoLogin(): React.ReactElement | null {
+ return (
+ <TimelinePostEditCard>
+ <div className="mt-3 mb-4">
+ <Trans
+ i18nKey="timeline.postNoLogin"
+ components={{ l: <Link to="/login" /> }}
+ />
+ </div>
+ </TimelinePostEditCard>
+ );
+}
diff --git a/FrontEnd/src/views/timeline/TimelinePostListView.tsx b/FrontEnd/src/views/timeline/TimelinePostListView.tsx
new file mode 100644
index 00000000..f6649e9e
--- /dev/null
+++ b/FrontEnd/src/views/timeline/TimelinePostListView.tsx
@@ -0,0 +1,75 @@
+import React, { Fragment } from "react";
+
+import { HttpTimelinePostInfo } from "@/http/timeline";
+
+import TimelinePostView from "./TimelinePostView";
+import TimelineDateLabel from "./TimelineDateLabel";
+
+function dateEqual(left: Date, right: Date): boolean {
+ return (
+ left.getDate() == right.getDate() &&
+ left.getMonth() == right.getMonth() &&
+ left.getFullYear() == right.getFullYear()
+ );
+}
+
+export interface TimelinePostListViewProps {
+ posts: HttpTimelinePostInfo[];
+ onReload: () => void;
+}
+
+const TimelinePostListView: React.FC<TimelinePostListViewProps> = (props) => {
+ const { posts, onReload } = props;
+
+ const groupedPosts = React.useMemo<
+ {
+ date: Date;
+ posts: (HttpTimelinePostInfo & { index: number })[];
+ }[]
+ >(() => {
+ const result: {
+ date: Date;
+ posts: (HttpTimelinePostInfo & { index: number })[];
+ }[] = [];
+ let index = 0;
+ for (const post of posts) {
+ const time = new Date(post.time);
+ if (result.length === 0) {
+ result.push({ date: time, posts: [{ ...post, index }] });
+ } else {
+ const lastGroup = result[result.length - 1];
+ if (dateEqual(lastGroup.date, time)) {
+ lastGroup.posts.push({ ...post, index });
+ } else {
+ result.push({ date: time, posts: [{ ...post, index }] });
+ }
+ }
+ index++;
+ }
+ return result;
+ }, [posts]);
+
+ return (
+ <>
+ {groupedPosts.map((group) => {
+ return (
+ <Fragment key={group.date.toDateString()}>
+ <TimelineDateLabel date={group.date} />
+ {group.posts.map((post) => {
+ return (
+ <TimelinePostView
+ key={post.id}
+ post={post}
+ onChanged={onReload}
+ onDeleted={onReload}
+ />
+ );
+ })}
+ </Fragment>
+ );
+ })}
+ </>
+ );
+};
+
+export default TimelinePostListView;
diff --git a/FrontEnd/src/views/timeline/TimelinePostView.tsx b/FrontEnd/src/views/timeline/TimelinePostView.tsx
new file mode 100644
index 00000000..086176f8
--- /dev/null
+++ b/FrontEnd/src/views/timeline/TimelinePostView.tsx
@@ -0,0 +1,159 @@
+import React from "react";
+import classnames from "classnames";
+import { Link } from "react-router-dom";
+
+import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline";
+
+import { pushAlert } from "@/services/alert";
+
+import useClickOutside from "@/utilities/useClickOutside";
+
+import UserAvatar from "../common/user/UserAvatar";
+import Card from "../common/Card";
+import FlatButton from "../common/button/FlatButton";
+import ConfirmDialog from "../common/dailog/ConfirmDialog";
+import TimelineLine from "./TimelineLine";
+import TimelinePostContentView from "./TimelinePostContentView";
+import PostPropertyChangeDialog from "./PostPropertyChangeDialog";
+
+export interface TimelinePostViewProps {
+ post: HttpTimelinePostInfo;
+ className?: string;
+ style?: React.CSSProperties;
+ cardStyle?: React.CSSProperties;
+ onChanged: (post: HttpTimelinePostInfo) => void;
+ onDeleted: () => void;
+}
+
+const TimelinePostView: React.FC<TimelinePostViewProps> = (props) => {
+ const { post, className, style, cardStyle, onChanged, onDeleted } = props;
+
+ const [operationMaskVisible, setOperationMaskVisible] =
+ React.useState<boolean>(false);
+ const [dialog, setDialog] = React.useState<
+ "delete" | "changeproperty" | null
+ >(null);
+
+ const [maskElement, setMaskElement] = React.useState<HTMLElement | null>(
+ null
+ );
+
+ useClickOutside(maskElement, () => setOperationMaskVisible(false));
+
+ const cardRef = React.useRef<HTMLDivElement>(null);
+ React.useEffect(() => {
+ const cardIntersectionObserver = new IntersectionObserver(([e]) => {
+ if (e.intersectionRatio > 0) {
+ if (cardRef.current != null) {
+ cardRef.current.style.animationName = "timeline-post-enter";
+ }
+ }
+ });
+ if (cardRef.current) {
+ cardIntersectionObserver.observe(cardRef.current);
+ }
+
+ return () => {
+ cardIntersectionObserver.disconnect();
+ };
+ }, []);
+
+ return (
+ <div
+ id={`timeline-post-${post.id}`}
+ className={classnames("timeline-item", className)}
+ style={style}
+ >
+ <TimelineLine center="node" />
+ <Card
+ ref={cardRef}
+ className="timeline-item-card enter-animation"
+ style={cardStyle}
+ >
+ {post.editable ? (
+ <i
+ className="bi-chevron-down icon-button primary-enhance cru-float-right"
+ onClick={(e) => {
+ setOperationMaskVisible(true);
+ e.stopPropagation();
+ }}
+ />
+ ) : null}
+ <div className="timeline-item-header">
+ <span className="me-2">
+ <span>
+ <Link to={"/users/" + props.post.author.username}>
+ <UserAvatar
+ username={post.author.username}
+ className="timeline-avatar me-1"
+ />
+ </Link>
+ <small className="text-dark me-2">{post.author.nickname}</small>
+ <small className="text-secondary white-space-no-wrap">
+ {new Date(post.time).toLocaleTimeString()}
+ </small>
+ </span>
+ </span>
+ </div>
+ <div className="timeline-content">
+ <TimelinePostContentView post={post} />
+ </div>
+ {operationMaskVisible ? (
+ <div
+ ref={setMaskElement}
+ className="timeline-post-item-options-mask"
+ onClick={() => {
+ setOperationMaskVisible(false);
+ }}
+ >
+ <FlatButton
+ text="changeProperty"
+ onClick={(e) => {
+ setDialog("changeproperty");
+ e.stopPropagation();
+ }}
+ />
+ <FlatButton
+ text="delete"
+ color="danger"
+ onClick={(e) => {
+ setDialog("delete");
+ e.stopPropagation();
+ }}
+ />
+ </div>
+ ) : null}
+ </Card>
+ <ConfirmDialog
+ title="timeline.post.deleteDialog.title"
+ body="timeline.post.deleteDialog.prompt"
+ open={dialog === "delete"}
+ onClose={() => {
+ setDialog(null);
+ setOperationMaskVisible(false);
+ }}
+ onConfirm={() => {
+ void getHttpTimelineClient()
+ .deletePost(post.timelineName, post.id)
+ .then(onDeleted, () => {
+ pushAlert({
+ type: "danger",
+ message: "timeline.deletePostFailed",
+ });
+ });
+ }}
+ />
+ <PostPropertyChangeDialog
+ open={dialog === "changeproperty"}
+ onClose={() => {
+ setDialog(null);
+ setOperationMaskVisible(false);
+ }}
+ post={post}
+ onSuccess={onChanged}
+ />
+ </div>
+ );
+};
+
+export default TimelinePostView;
diff --git a/FrontEnd/src/views/timeline/TimelinePropertyChangeDialog.tsx b/FrontEnd/src/views/timeline/TimelinePropertyChangeDialog.tsx
new file mode 100644
index 00000000..cd5c46da
--- /dev/null
+++ b/FrontEnd/src/views/timeline/TimelinePropertyChangeDialog.tsx
@@ -0,0 +1,88 @@
+import React from "react";
+
+import {
+ getHttpTimelineClient,
+ HttpTimelineInfo,
+ HttpTimelinePatchRequest,
+ kTimelineVisibilities,
+ TimelineVisibility,
+} from "@/http/timeline";
+
+import OperationDialog from "../common/dailog/OperationDialog";
+
+export interface TimelinePropertyChangeDialogProps {
+ open: boolean;
+ close: () => void;
+ timeline: HttpTimelineInfo;
+ onChange: () => void;
+}
+
+const labelMap: { [key in TimelineVisibility]: string } = {
+ Private: "timeline.visibility.private",
+ Public: "timeline.visibility.public",
+ Register: "timeline.visibility.register",
+};
+
+const TimelinePropertyChangeDialog: React.FC<
+ TimelinePropertyChangeDialogProps
+> = (props) => {
+ const { timeline, onChange } = props;
+
+ return (
+ <OperationDialog
+ title={"timeline.dialogChangeProperty.title"}
+ inputScheme={
+ [
+ {
+ type: "text",
+ label: "timeline.dialogChangeProperty.titleField",
+ initValue: timeline.title,
+ },
+ {
+ type: "select",
+ label: "timeline.dialogChangeProperty.visibility",
+ options: kTimelineVisibilities.map((v) => ({
+ label: labelMap[v],
+ value: v,
+ })),
+ initValue: timeline.visibility,
+ },
+ {
+ type: "text",
+ label: "timeline.dialogChangeProperty.description",
+ initValue: timeline.description,
+ },
+ {
+ type: "color",
+ label: "timeline.dialogChangeProperty.color",
+ initValue: timeline.color ?? null,
+ canBeNull: true,
+ },
+ ] as const
+ }
+ open={props.open}
+ onClose={props.close}
+ onProcess={([newTitle, newVisibility, newDescription, newColor]) => {
+ const req: HttpTimelinePatchRequest = {};
+ if (newTitle !== timeline.title) {
+ req.title = newTitle;
+ }
+ if (newVisibility !== timeline.visibility) {
+ req.visibility = newVisibility as TimelineVisibility;
+ }
+ if (newDescription !== timeline.description) {
+ req.description = newDescription;
+ }
+ const nc = newColor ?? "";
+ if (nc !== timeline.color) {
+ req.color = nc;
+ }
+ return getHttpTimelineClient()
+ .patchTimeline(timeline.name, req)
+ .then(onChange);
+ }}
+ />
+ );
+};
+
+export default TimelinePropertyChangeDialog;
diff --git a/FrontEnd/src/views/timeline/index.css b/FrontEnd/src/views/timeline/index.css
new file mode 100644
index 00000000..6929f9ae
--- /dev/null
+++ b/FrontEnd/src/views/timeline/index.css
@@ -0,0 +1,246 @@
+@keyframes timeline-enter {
+ from {
+ transform: translate(0, -100%);
+ }
+}
+
+.timeline {
+ z-index: 0;
+ position: relative;
+ width: 100%;
+ animation: 1s timeline-enter;
+}
+
+@keyframes timeline-line-node-noncurrent {
+ to {
+ box-shadow: 0 0 20px 3px var(--cru-primary-l1-color);
+ }
+}
+
+@keyframes timeline-line-node-current {
+ to {
+ box-shadow: 0 0 20px 3px var(--cru-primary-enhance-l1-color);
+ }
+}
+
+@keyframes timeline-line-node-loading {
+ to {
+ box-shadow: 0 0 20px 3px var(--cru-primary-l1-color);
+ }
+}
+
+@keyframes timeline-line-node-loading-edge {
+ from {
+ transform: rotate(0turn);
+ }
+ to {
+ transform: rotate(1turn);
+ }
+}
+
+@keyframes timeline-top-loading-enter {
+ from {
+ transform: translate(0, -100%);
+ }
+}
+
+@keyframes timeline-post-enter {
+ from {
+ transform: translate(0, -100%);
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+.timeline-top-loading-enter {
+ animation: 1s timeline-top-loading-enter;
+}
+
+.timeline-line {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ width: 30px;
+ position: absolute;
+ z-index: 1;
+ left: 2em;
+ top: 0;
+ bottom: 0;
+ transition: left 0.5s;
+}
+
+@media (max-width: 575.98px) {
+ .timeline-line {
+ left: 1em;
+ }
+}
+
+.timeline-line .segment {
+ width: 7px;
+ background: var(--cru-primary-color);
+}
+.timeline-line .segment.start {
+ height: 1.8em;
+ flex: 0 0 auto;
+}
+.timeline-line .segment.end {
+ flex: 1 1 auto;
+}
+.timeline-line .segment.current-end {
+ height: 2em;
+ flex: 0 0 auto;
+ background: linear-gradient(var(--cru-primary-enhance-color), white);
+}
+.timeline-line .node-container {
+ flex: 0 0 auto;
+ position: relative;
+ width: 18px;
+ height: 18px;
+}
+.timeline-line .node {
+ width: 20px;
+ height: 20px;
+ position: absolute;
+ background: var(--cru-primary-color);
+ left: -1px;
+ top: -1px;
+ border-radius: 50%;
+ box-sizing: border-box;
+ z-index: 1;
+ animation: 1s infinite alternate;
+ animation-name: timeline-line-node-noncurrent;
+}
+.timeline-line .node-loading-edge {
+ color: var(--cru-primary-color);
+ width: 38px;
+ height: 38px;
+ position: absolute;
+ left: -10px;
+ top: -10px;
+ box-sizing: border-box;
+ z-index: 2;
+ animation: 1.5s linear infinite timeline-line-node-loading-edge;
+}
+.timeline-line.current .segment.start {
+ background: linear-gradient(
+ var(--cru-primary-color),
+ var(--cru-primary-enhance-color)
+ );
+}
+
+.timeline-line.current .segment.end {
+ background: var(--cru-primary-enhance-color);
+}
+
+.timeline-line.current .node {
+ background: var(--cru-primary-enhance-color);
+ animation-name: timeline-line-node-current;
+}
+
+.timeline-line.loading .node {
+ background: var(--cru-primary-color);
+ animation-name: timeline-line-node-loading;
+}
+
+.timeline-item {
+ position: relative;
+ padding: 0.5em;
+}
+
+.timeline-item-card {
+ position: relative;
+ padding: 0.3em 0.5em 1em 4em;
+}
+
+.timeline-item-card.enter-animation {
+ animation: 0.6s forwards;
+ opacity: 0;
+}
+
+@media (max-width: 575.98px) {
+ .timeline-item-card {
+ padding-left: 3em;
+ }
+}
+
+.timeline-item-header {
+ display: flex;
+ align-items: center;
+}
+
+.timeline-avatar {
+ border-radius: 50%;
+ width: 2em;
+ height: 2em;
+}
+
+.timeline-item-delete-button {
+ position: absolute;
+ right: 0;
+ bottom: 0;
+}
+
+.timeline-content {
+ white-space: pre-line;
+}
+
+.timeline-content-image {
+ max-width: 80%;
+ max-height: 200px;
+}
+
+.timeline-date-item {
+ position: relative;
+ padding: 0.3em 0 0.3em 4em;
+}
+
+.timeline-date-item-badge {
+ display: inline-block;
+ padding: 0.1em 0.4em;
+ border-radius: 0.4em;
+ background: #7c7c7c;
+ color: white;
+ font-size: 0.8em;
+}
+
+.timeline-post-item-options-mask {
+ background: rgba(255, 255, 255, 0.85);
+ z-index: 100;
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: 0;
+ bottom: 0;
+
+ display: flex;
+ justify-content: space-around;
+ align-items: center;
+
+ border-radius: var(--cru-card-border-radius);
+}
+
+.timeline-sync-state-badge {
+ font-size: 0.8em;
+ padding: 3px 8px;
+ border-radius: 5px;
+ background: #e8fbff;
+}
+
+.timeline-sync-state-badge-pin {
+ display: inline-block;
+ width: 0.4em;
+ height: 0.4em;
+ border-radius: 50%;
+ vertical-align: middle;
+ margin-right: 0.6em;
+}
+
+.timeline-template-card {
+ position: fixed;
+ z-index: 1029;
+ top: 56px;
+ right: 0;
+ margin: 0.5em;
+}
diff --git a/FrontEnd/src/views/timeline/index.tsx b/FrontEnd/src/views/timeline/index.tsx
index 02d773dc..4faf8af8 100644
--- a/FrontEnd/src/views/timeline/index.tsx
+++ b/FrontEnd/src/views/timeline/index.tsx
@@ -1,28 +1,84 @@
import React from "react";
+import { HubConnectionState } from "@microsoft/signalr";
import { useParams } from "react-router-dom";
import { UiLogicError } from "@/common";
+import { HttpTimelineInfo } from "@/http/timeline";
+import useReverseScrollPositionRemember from "@/utilities/useReverseScrollPositionRemember";
+import { generatePalette, setPalette } from "@/palette";
-import TimelinePageTemplate from "../timeline-common/TimelinePageTemplate";
+import Timeline from "./Timeline";
import TimelineCard from "./TimelineCard";
const TimelinePage: React.FC = () => {
- const { name } = useParams();
+ const { owner: ownerUsername, timeline: timelineNameParam } = useParams();
- if (name == null) {
- throw new UiLogicError("No route param 'name'.");
- }
+ if (ownerUsername == null || ownerUsername == "")
+ throw new UiLogicError("Route param owner is not set.");
+
+ const timelineName =
+ timelineNameParam == null || timelineNameParam === ""
+ ? "self"
+ : timelineNameParam;
+
+ const [timeline, setTimeline] = React.useState<HttpTimelineInfo | null>(null);
const [reloadKey, setReloadKey] = React.useState<number>(0);
+ const reload = (): void => setReloadKey(reloadKey + 1);
+
+ const [connectionStatus, setConnectionStatus] =
+ React.useState<HubConnectionState>(HubConnectionState.Connecting);
+
+ useReverseScrollPositionRemember();
+
+ React.useEffect(() => {
+ if (timeline != null && timeline.color != null) {
+ return setPalette(generatePalette({ primary: timeline.color }));
+ }
+ }, [timeline]);
+
+ const cardCollapseLocalStorageKey = `timeline.${ownerUsername}.${timelineName}.cardCollapse`;
+
+ const [cardCollapse, setCardCollapse] = React.useState<boolean>(true);
+
+ React.useEffect(() => {
+ const savedCollapse = window.localStorage.getItem(
+ cardCollapseLocalStorageKey
+ );
+ setCardCollapse(savedCollapse == null ? true : savedCollapse === "true");
+ }, [cardCollapseLocalStorageKey]);
+
+ const toggleCardCollapse = (): void => {
+ const newState = !cardCollapse;
+ setCardCollapse(newState);
+ window.localStorage.setItem(
+ cardCollapseLocalStorageKey,
+ newState.toString()
+ );
+ };
return (
- <TimelinePageTemplate
- timelineName={name}
- notFoundI18nKey="timeline.timelineNotExist"
- reloadKey={reloadKey}
- CardComponent={TimelineCard}
- onReload={() => setReloadKey(reloadKey + 1)}
- />
+ <>
+ {timeline != null ? (
+ <TimelineCard
+ className="timeline-template-card"
+ timeline={timeline}
+ collapse={cardCollapse}
+ toggleCollapse={toggleCardCollapse}
+ onReload={reload}
+ connectionStatus={connectionStatus}
+ />
+ ) : null}
+ <div className="container">
+ <Timeline
+ timelineName={timelineName}
+ reloadKey={reloadKey}
+ onReload={reload}
+ onTimelineLoaded={(t) => setTimeline(t)}
+ onConnectionStateChanged={setConnectionStatus}
+ />
+ </div>
+ </>
);
};