aboutsummaryrefslogtreecommitdiff
path: root/FrontEnd/src/pages/timeline
diff options
context:
space:
mode:
authorcrupest <crupest@outlook.com>2023-07-30 23:47:53 +0800
committercrupest <crupest@outlook.com>2023-07-30 23:47:53 +0800
commit538d6830a0022b49b99695095d85e567b0c86e71 (patch)
treea0c4d164b05d03f636d603b28f77ca881c16ef10 /FrontEnd/src/pages/timeline
parenta148f11c193d35ba489f887ed393aedf58a1c714 (diff)
downloadtimeline-538d6830a0022b49b99695095d85e567b0c86e71.tar.gz
timeline-538d6830a0022b49b99695095d85e567b0c86e71.tar.bz2
timeline-538d6830a0022b49b99695095d85e567b0c86e71.zip
...
Diffstat (limited to 'FrontEnd/src/pages/timeline')
-rw-r--r--FrontEnd/src/pages/timeline/CollapseButton.tsx21
-rw-r--r--FrontEnd/src/pages/timeline/ConnectionStatusBadge.css36
-rw-r--r--FrontEnd/src/pages/timeline/ConnectionStatusBadge.tsx41
-rw-r--r--FrontEnd/src/pages/timeline/MarkdownPostEdit.css21
-rw-r--r--FrontEnd/src/pages/timeline/MarkdownPostEdit.tsx215
-rw-r--r--FrontEnd/src/pages/timeline/Timeline.css244
-rw-r--r--FrontEnd/src/pages/timeline/Timeline.tsx207
-rw-r--r--FrontEnd/src/pages/timeline/TimelineCard.tsx167
-rw-r--r--FrontEnd/src/pages/timeline/TimelineDateLabel.tsx19
-rw-r--r--FrontEnd/src/pages/timeline/TimelineDeleteDialog.tsx61
-rw-r--r--FrontEnd/src/pages/timeline/TimelineEmptyItem.tsx25
-rw-r--r--FrontEnd/src/pages/timeline/TimelineLine.tsx51
-rw-r--r--FrontEnd/src/pages/timeline/TimelineLoading.tsx16
-rw-r--r--FrontEnd/src/pages/timeline/TimelineMember.css8
-rw-r--r--FrontEnd/src/pages/timeline/TimelineMember.tsx202
-rw-r--r--FrontEnd/src/pages/timeline/TimelinePostContentView.tsx187
-rw-r--r--FrontEnd/src/pages/timeline/TimelinePostEdit.css10
-rw-r--r--FrontEnd/src/pages/timeline/TimelinePostEdit.tsx267
-rw-r--r--FrontEnd/src/pages/timeline/TimelinePostEditCard.tsx31
-rw-r--r--FrontEnd/src/pages/timeline/TimelinePostEditNoLogin.tsx18
-rw-r--r--FrontEnd/src/pages/timeline/TimelinePostListView.tsx76
-rw-r--r--FrontEnd/src/pages/timeline/TimelinePostView.tsx149
-rw-r--r--FrontEnd/src/pages/timeline/TimelinePropertyChangeDialog.tsx87
-rw-r--r--FrontEnd/src/pages/timeline/index.tsx23
24 files changed, 2182 insertions, 0 deletions
diff --git a/FrontEnd/src/pages/timeline/CollapseButton.tsx b/FrontEnd/src/pages/timeline/CollapseButton.tsx
new file mode 100644
index 00000000..8270e160
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/CollapseButton.tsx
@@ -0,0 +1,21 @@
+import * as React from "react";
+
+import IconButton from "@/views/common/button/IconButton";
+
+const CollapseButton: React.FC<{
+ collapse: boolean;
+ onClick: () => void;
+ className?: string;
+ style?: React.CSSProperties;
+}> = ({ collapse, onClick, className, style }) => {
+ return (
+ <IconButton
+ icon={collapse ? "arrows-angle-expand" : "arrows-angle-contract"}
+ onClick={onClick}
+ className={className}
+ style={style}
+ />
+ );
+};
+
+export default CollapseButton;
diff --git a/FrontEnd/src/pages/timeline/ConnectionStatusBadge.css b/FrontEnd/src/pages/timeline/ConnectionStatusBadge.css
new file mode 100644
index 00000000..7fe83b9b
--- /dev/null
+++ b/FrontEnd/src/pages/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/pages/timeline/ConnectionStatusBadge.tsx b/FrontEnd/src/pages/timeline/ConnectionStatusBadge.tsx
new file mode 100644
index 00000000..2b820454
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/ConnectionStatusBadge.tsx
@@ -0,0 +1,41 @@
+import * as 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/pages/timeline/MarkdownPostEdit.css b/FrontEnd/src/pages/timeline/MarkdownPostEdit.css
new file mode 100644
index 00000000..e36be992
--- /dev/null
+++ b/FrontEnd/src/pages/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/pages/timeline/MarkdownPostEdit.tsx b/FrontEnd/src/pages/timeline/MarkdownPostEdit.tsx
new file mode 100644
index 00000000..9c497108
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/MarkdownPostEdit.tsx
@@ -0,0 +1,215 @@
+import * as 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 "@/views/common/button/FlatButton";
+import TabPages from "@/views/common/tab/TabPages";
+import ConfirmDialog from "@/views/common/dialog/ConfirmDialog";
+import Spinner from "@/views/common/Spinner";
+import IconButton from "@/views/common/button/IconButton";
+
+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>
+ <IconButton
+ icon="x"
+ color="danger"
+ large
+ className="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"
+ />
+ <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 }}
+ />
+ ),
+ },
+ ]}
+ />
+ <ConfirmDialog
+ onClose={() => setShowLeaveConfirmDialog(false)}
+ onConfirm={onClose}
+ open={showLeaveConfirmDialog}
+ title="timeline.dropDraft"
+ body="timeline.confirmLeave"
+ />
+ </>
+ );
+};
+
+export default MarkdownPostEdit;
diff --git a/FrontEnd/src/pages/timeline/Timeline.css b/FrontEnd/src/pages/timeline/Timeline.css
new file mode 100644
index 00000000..4dd4fdcc
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/Timeline.css
@@ -0,0 +1,244 @@
+.timeline {
+ z-index: 0;
+ position: relative;
+ width: 100%;
+}
+
+@keyframes timeline-line-node {
+ 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;
+}
+.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.5em 0.5em 0.5em 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-card {
+ position: fixed;
+ z-index: 1029;
+ top: 56px;
+ right: 0;
+ margin: 0.5em;
+}
+
+.timeline-top {
+ position: sticky;
+ top: 56px;
+}
diff --git a/FrontEnd/src/pages/timeline/Timeline.tsx b/FrontEnd/src/pages/timeline/Timeline.tsx
new file mode 100644
index 00000000..3a7fbd00
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/Timeline.tsx
@@ -0,0 +1,207 @@
+import * as React from "react";
+import classnames from "classnames";
+import { useScrollToBottom } from "@/utilities/hooks";
+import { HubConnectionState } from "@microsoft/signalr";
+
+import {
+ HttpForbiddenError,
+ HttpNetworkError,
+ HttpNotFoundError,
+} from "@/http/common";
+import {
+ getHttpTimelineClient,
+ HttpTimelineInfo,
+ HttpTimelinePostInfo,
+} from "@/http/timeline";
+
+import { useUser } from "@/services/user";
+import { getTimelinePostUpdate$ } from "@/services/timeline";
+
+import TimelinePostListView from "./TimelinePostListView";
+import TimelineEmptyItem from "./TimelineEmptyItem";
+import TimelineLoading from "./TimelineLoading";
+import TimelinePostEdit from "./TimelinePostEdit";
+import TimelinePostEditNoLogin from "./TimelinePostEditNoLogin";
+import TimelineCard from "./TimelineCard";
+
+import "./Timeline.css";
+
+export interface TimelineProps {
+ className?: string;
+ style?: React.CSSProperties;
+ timelineOwner: string;
+ timelineName: string;
+}
+
+const Timeline: React.FC<TimelineProps> = (props) => {
+ const { timelineOwner, timelineName, className, style } = props;
+
+ const user = useUser();
+
+ const [timeline, setTimeline] = React.useState<HttpTimelineInfo | null>(null);
+ const [posts, setPosts] = React.useState<HttpTimelinePostInfo[] | null>(null);
+ const [signalrState, setSignalrState] = React.useState<HubConnectionState>(
+ HubConnectionState.Connecting
+ );
+ const [error, setError] = React.useState<
+ "offline" | "forbid" | "notfound" | "error" | null
+ >(null);
+
+ const [currentPage, setCurrentPage] = React.useState(1);
+ const [totalPage, setTotalPage] = React.useState(0);
+
+ const [timelineReloadKey, setTimelineReloadKey] = React.useState(0);
+ const [postsReloadKey, setPostsReloadKey] = React.useState(0);
+
+ const updateTimeline = (): void => setTimelineReloadKey((o) => o + 1);
+ const updatePosts = (): void => setPostsReloadKey((o) => o + 1);
+
+ React.useEffect(() => {
+ setTimeline(null);
+ setPosts(null);
+ setError(null);
+ setSignalrState(HubConnectionState.Connecting);
+ }, [timelineOwner, timelineName]);
+
+ React.useEffect(() => {
+ getHttpTimelineClient()
+ .getTimeline(timelineOwner, timelineName)
+ .then(
+ (t) => {
+ setTimeline(t);
+ },
+ (error) => {
+ if (error instanceof HttpNetworkError) {
+ setError("offline");
+ } else if (error instanceof HttpForbiddenError) {
+ setError("forbid");
+ } else if (error instanceof HttpNotFoundError) {
+ setError("notfound");
+ } else {
+ console.error(error);
+ setError("error");
+ }
+ }
+ );
+ }, [timelineOwner, timelineName, timelineReloadKey]);
+
+ React.useEffect(() => {
+ getHttpTimelineClient()
+ .listPost(timelineOwner, timelineName, 1)
+ .then(
+ (page) => {
+ setPosts(
+ page.items.filter((p): p is HttpTimelinePostInfo => !p.deleted)
+ );
+ setTotalPage(page.totalPageCount);
+ },
+ (error) => {
+ if (error instanceof HttpNetworkError) {
+ setError("offline");
+ } else if (error instanceof HttpForbiddenError) {
+ setError("forbid");
+ } else if (error instanceof HttpNotFoundError) {
+ setError("notfound");
+ } else {
+ console.error(error);
+ setError("error");
+ }
+ }
+ );
+ }, [timelineOwner, timelineName, postsReloadKey]);
+
+ React.useEffect(() => {
+ const timelinePostUpdate$ = getTimelinePostUpdate$(
+ timelineOwner,
+ timelineName
+ );
+ const subscription = timelinePostUpdate$.subscribe(({ update, state }) => {
+ if (update) {
+ setPostsReloadKey((o) => o + 1);
+ }
+ setSignalrState(state);
+ });
+ return () => {
+ subscription.unsubscribe();
+ };
+ }, [timelineOwner, timelineName]);
+
+ useScrollToBottom(() => {
+ console.log(`Load page ${currentPage + 1}.`);
+ setCurrentPage(currentPage + 1);
+ void getHttpTimelineClient()
+ .listPost(timelineOwner, timelineName, currentPage + 1)
+ .then(
+ (page) => {
+ const ps = page.items.filter(
+ (p): p is HttpTimelinePostInfo => !p.deleted
+ );
+ setPosts((old) => [...(old ?? []), ...ps]);
+ },
+ (error) => {
+ if (error instanceof HttpNetworkError) {
+ setError("offline");
+ } else if (error instanceof HttpForbiddenError) {
+ setError("forbid");
+ } else if (error instanceof HttpNotFoundError) {
+ setError("notfound");
+ } else {
+ console.error(error);
+ setError("error");
+ }
+ }
+ );
+ }, currentPage < totalPage);
+
+ if (error === "offline") {
+ return (
+ <div className={className} style={style}>
+ Offline.
+ </div>
+ );
+ } else if (error === "notfound") {
+ return (
+ <div className={className} style={style}>
+ Not exist.
+ </div>
+ );
+ } else if (error === "forbid") {
+ return (
+ <div className={className} style={style}>
+ Forbid.
+ </div>
+ );
+ } else if (error === "error") {
+ return (
+ <div className={className} style={style}>
+ Error.
+ </div>
+ );
+ }
+ return (
+ <>
+ {timeline == null && posts == null && <TimelineLoading />}
+ {timeline && (
+ <TimelineCard
+ className="timeline-card"
+ timeline={timeline}
+ connectionStatus={signalrState}
+ onReload={updateTimeline}
+ />
+ )}
+ {posts && (
+ <div style={style} className={classnames("timeline", className)}>
+ <TimelineEmptyItem className="timeline-top" height={50} />
+ {timeline?.postable ? (
+ <TimelinePostEdit timeline={timeline} onPosted={updatePosts} />
+ ) : user == null ? (
+ <TimelinePostEditNoLogin />
+ ) : null}
+ <TimelinePostListView posts={posts} onReload={updatePosts} />
+ </div>
+ )}
+ </>
+ );
+};
+
+export default Timeline;
diff --git a/FrontEnd/src/pages/timeline/TimelineCard.tsx b/FrontEnd/src/pages/timeline/TimelineCard.tsx
new file mode 100644
index 00000000..8ce133c0
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelineCard.tsx
@@ -0,0 +1,167 @@
+import * as React from "react";
+import { useTranslation } from "react-i18next";
+import classnames from "classnames";
+import { HubConnectionState } from "@microsoft/signalr";
+
+import { useIsSmallScreen } from "@/utilities/hooks";
+import { timelineVisibilityTooltipTranslationMap } from "@/services/timeline";
+import { useUser } from "@/services/user";
+import { pushAlert } from "@/services/alert";
+import { HttpTimelineInfo } from "@/http/timeline";
+import { getHttpBookmarkClient } from "@/http/bookmark";
+
+import UserAvatar from "@/views/common/user/UserAvatar";
+import PopupMenu from "@/views/common/menu/PopupMenu";
+import FullPageDialog from "@/views/common/dialog/FullPageDialog";
+import Card from "@/views/common/Card";
+import TimelineDeleteDialog from "./TimelineDeleteDialog";
+import ConnectionStatusBadge from "./ConnectionStatusBadge";
+import CollapseButton from "./CollapseButton";
+import { TimelineMemberDialog } from "./TimelineMember";
+import TimelinePropertyChangeDialog from "./TimelinePropertyChangeDialog";
+import IconButton from "@/views/common/button/IconButton";
+
+export interface TimelinePageCardProps {
+ timeline: HttpTimelineInfo;
+ connectionStatus: HubConnectionState;
+ className?: string;
+ onReload: () => void;
+}
+
+const TimelineCard: React.FC<TimelinePageCardProps> = (props) => {
+ const { timeline, connectionStatus, onReload, className } = props;
+
+ const { t } = useTranslation();
+
+ const [dialog, setDialog] = React.useState<
+ "member" | "property" | "delete" | null
+ >(null);
+
+ const [collapse, setCollapse] = React.useState(true);
+ const toggleCollapse = (): void => {
+ setCollapse((o) => !o);
+ };
+
+ 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.nameV2}</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">
+ {user != null ? (
+ <IconButton
+ icon={timeline.isBookmark ? "bookmark-fill" : "bookmark"}
+ className="me-3"
+ onClick={() => {
+ getHttpBookmarkClient()
+ [timeline.isBookmark ? "delete" : "post"](
+ user.username,
+ timeline.owner.username,
+ timeline.nameV2,
+ )
+ .then(onReload, () => {
+ pushAlert({
+ message: timeline.isBookmark
+ ? "timeline.removeBookmarkFail"
+ : "timeline.addBookmarkFail",
+ type: "danger",
+ });
+ });
+ }}
+ />
+ ) : null}
+ <IconButton
+ icon="people"
+ className="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"
+ >
+ <IconButton icon="three-dots-vertical" />
+ </PopupMenu>
+ ) : null}
+ </div>
+ </>
+ );
+
+ return (
+ <>
+ <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}
+ open={dialog === "delete"}
+ close={() => setDialog(null)}
+ />
+ </>
+ );
+};
+
+export default TimelineCard;
diff --git a/FrontEnd/src/pages/timeline/TimelineDateLabel.tsx b/FrontEnd/src/pages/timeline/TimelineDateLabel.tsx
new file mode 100644
index 00000000..5f4ac706
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelineDateLabel.tsx
@@ -0,0 +1,19 @@
+import * as 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/pages/timeline/TimelineDeleteDialog.tsx b/FrontEnd/src/pages/timeline/TimelineDeleteDialog.tsx
new file mode 100644
index 00000000..d5b22aee
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelineDeleteDialog.tsx
@@ -0,0 +1,61 @@
+import * as React from "react";
+import { useNavigate } from "react-router-dom";
+import { Trans } from "react-i18next";
+
+import { getHttpTimelineClient, HttpTimelineInfo } from "@/http/timeline";
+
+import OperationDialog from "@/views/common/dialog/OperationDialog";
+
+interface TimelineDeleteDialog {
+ timeline: HttpTimelineInfo;
+ open: boolean;
+ close: () => void;
+}
+
+const TimelineDeleteDialog: React.FC<TimelineDeleteDialog> = (props) => {
+ const navigate = useNavigate();
+
+ const { timeline } = props;
+
+ return (
+ <OperationDialog
+ open={props.open}
+ onClose={props.close}
+ title="timeline.deleteDialog.title"
+ color="danger"
+ inputPrompt={() => (
+ <Trans
+ i18nKey="timeline.deleteDialog.inputPrompt"
+ values={{ name: timeline.nameV2 }}
+ >
+ 0<code className="mx-2">1</code>2
+ </Trans>
+ )}
+ inputs={{
+ inputs: [
+ {
+ key: "name",
+ type: "text",
+ label: "",
+ },
+ ],
+ validator: ({ name }) => {
+ if (name !== timeline.nameV2) {
+ return { name: "timeline.deleteDialog.notMatch" };
+ }
+ },
+ }}
+ onProcess={() => {
+ return getHttpTimelineClient().deleteTimeline(
+ timeline.owner.username,
+ timeline.nameV2,
+ );
+ }}
+ onSuccessAndClose={() => {
+ navigate("/", { replace: true });
+ }}
+ />
+ );
+};
+
+export default TimelineDeleteDialog;
diff --git a/FrontEnd/src/pages/timeline/TimelineEmptyItem.tsx b/FrontEnd/src/pages/timeline/TimelineEmptyItem.tsx
new file mode 100644
index 00000000..5e0728d4
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelineEmptyItem.tsx
@@ -0,0 +1,25 @@
+import * as 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/pages/timeline/TimelineLine.tsx b/FrontEnd/src/pages/timeline/TimelineLine.tsx
new file mode 100644
index 00000000..4a87e6e0
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelineLine.tsx
@@ -0,0 +1,51 @@
+import * as 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/pages/timeline/TimelineLoading.tsx b/FrontEnd/src/pages/timeline/TimelineLoading.tsx
new file mode 100644
index 00000000..f876cba9
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelineLoading.tsx
@@ -0,0 +1,16 @@
+import * as 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/pages/timeline/TimelineMember.css b/FrontEnd/src/pages/timeline/TimelineMember.css
new file mode 100644
index 00000000..adb78764
--- /dev/null
+++ b/FrontEnd/src/pages/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/pages/timeline/TimelineMember.tsx b/FrontEnd/src/pages/timeline/TimelineMember.tsx
new file mode 100644
index 00000000..6c5d29ba
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelineMember.tsx
@@ -0,0 +1,202 @@
+import { useState } from "react";
+import * as React 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 "@/views/common/SearchInput";
+import UserAvatar from "@/views/common/user/UserAvatar";
+import Button from "@/views/common/button/Button";
+import Dialog from "@/views/common/dialog/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.owner.username,
+ timeline.nameV2,
+ 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.owner.username,
+ timeline.nameV2,
+ 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/pages/timeline/TimelinePostContentView.tsx b/FrontEnd/src/pages/timeline/TimelinePostContentView.tsx
new file mode 100644
index 00000000..41080e10
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelinePostContentView.tsx
@@ -0,0 +1,187 @@
+import * as React from "react";
+import classnames from "classnames";
+import { marked } from "marked";
+
+import { UiLogicError } from "@/common";
+
+import { HttpNetworkError } from "@/http/common";
+import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline";
+
+import { useUser } from "@/services/user";
+
+import Skeleton from "@/views/common/Skeleton";
+import LoadFailReload from "@/views/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.timelineOwnerV2, post.timelineNameV2, post.id)
+ .then(
+ (data) => {
+ if (subscribe) setText(data);
+ },
+ (error) => {
+ if (subscribe) {
+ if (error instanceof HttpNetworkError) {
+ setError("offline");
+ } else {
+ setError("error");
+ }
+ }
+ }
+ );
+
+ return () => {
+ subscribe = false;
+ };
+ }, [post.timelineOwnerV2, post.timelineNameV2, 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.timelineOwnerV2,
+ post.timelineNameV2,
+ post.id
+ )}
+ className={classnames(className, "timeline-content-image")}
+ style={style}
+ />
+ );
+};
+
+const MarkdownView: React.FC<TimelinePostContentViewProps> = (props) => {
+ const { post, className, style } = props;
+
+ 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.timelineOwnerV2, post.timelineNameV2, post.id)
+ .then(
+ (data) => {
+ if (subscribe) setMarkdown(data);
+ },
+ (error) => {
+ if (subscribe) {
+ if (error instanceof HttpNetworkError) {
+ setError("offline");
+ } else {
+ setError("error");
+ }
+ }
+ }
+ );
+
+ return () => {
+ subscribe = false;
+ };
+ }, [post.timelineOwnerV2, post.timelineNameV2, post.id, reloadKey]);
+
+ const markdownHtml = React.useMemo<string | null>(() => {
+ if (markdown == null) return null;
+ return marked.parse(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/pages/timeline/TimelinePostEdit.css b/FrontEnd/src/pages/timeline/TimelinePostEdit.css
new file mode 100644
index 00000000..9b7629e2
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelinePostEdit.css
@@ -0,0 +1,10 @@
+.timeline-post-edit {
+ position: sticky !important;
+ top: 106px;
+ z-index: 100;
+}
+
+.timeline-post-edit-image {
+ max-width: 100px;
+ max-height: 100px;
+}
diff --git a/FrontEnd/src/pages/timeline/TimelinePostEdit.tsx b/FrontEnd/src/pages/timeline/TimelinePostEdit.tsx
new file mode 100644
index 00000000..c1fa0dd9
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelinePostEdit.tsx
@@ -0,0 +1,267 @@
+import * as React from "react";
+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 "@/utilities/base64";
+
+import BlobImage from "@/views/common/BlobImage";
+import LoadingButton from "@/views/common/button/LoadingButton";
+import PopupMenu from "@/views/common/menu/PopupMenu";
+import MarkdownPostEdit from "./MarkdownPostEdit";
+import TimelinePostEditCard from "./TimelinePostEditCard";
+import IconButton from "@/views/common/button/IconButton";
+
+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 postKindIconMap: Record<PostKind, string> = {
+ text: "fonts",
+ markdown: "markdown",
+ image: "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.owner.username}.${timeline.nameV2}.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.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 (
+ <TimelinePostEditCard className={className} style={style}>
+ {showMarkdown ? (
+ <MarkdownPostEdit
+ className="cru-fill-parent"
+ onClose={() => setShowMarkdown(false)}
+ owner={timeline.owner.username}
+ timeline={timeline.nameV2}
+ 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: postKindIconMap[kind],
+ onClick: () => {
+ if (kind === "markdown") {
+ setShowMarkdown(true);
+ } else {
+ setKind(kind);
+ }
+ },
+ }))}
+ >
+ <IconButton large icon={postKindIconMap[kind]} />
+ </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/pages/timeline/TimelinePostEditCard.tsx b/FrontEnd/src/pages/timeline/TimelinePostEditCard.tsx
new file mode 100644
index 00000000..fdd53983
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelinePostEditCard.tsx
@@ -0,0 +1,31 @@
+import * as React from "react";
+import classnames from "classnames";
+
+import Card from "@/views/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" />
+ <Card className="timeline-item-card">{children}</Card>
+ </div>
+ );
+};
+
+export default TimelinePostEdit;
diff --git a/FrontEnd/src/pages/timeline/TimelinePostEditNoLogin.tsx b/FrontEnd/src/pages/timeline/TimelinePostEditNoLogin.tsx
new file mode 100644
index 00000000..1ef0a287
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelinePostEditNoLogin.tsx
@@ -0,0 +1,18 @@
+import * as 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/pages/timeline/TimelinePostListView.tsx b/FrontEnd/src/pages/timeline/TimelinePostListView.tsx
new file mode 100644
index 00000000..f878b004
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelinePostListView.tsx
@@ -0,0 +1,76 @@
+import { Fragment } from "react";
+import * as React 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/pages/timeline/TimelinePostView.tsx b/FrontEnd/src/pages/timeline/TimelinePostView.tsx
new file mode 100644
index 00000000..f7aec169
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelinePostView.tsx
@@ -0,0 +1,149 @@
+import * as React from "react";
+import classnames from "classnames";
+
+import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline";
+
+import { pushAlert } from "@/services/alert";
+
+import { useClickOutside } from "@/utilities/hooks";
+
+import UserAvatar from "@/views/common/user/UserAvatar";
+import Card from "@/views/common/Card";
+import FlatButton from "@/views/common/button/FlatButton";
+import ConfirmDialog from "@/views/common/dialog/ConfirmDialog";
+import TimelineLine from "./TimelineLine";
+import TimelinePostContentView from "./TimelinePostContentView";
+import IconButton from "@/views/common/button/IconButton";
+
+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
+ containerRef={cardRef}
+ className="timeline-item-card enter-animation"
+ style={cardStyle}
+ >
+ {post.editable ? (
+ <IconButton
+ icon="chevron-down"
+ color="primary"
+ className="cru-float-right"
+ onClick={(e) => {
+ setOperationMaskVisible(true);
+ e.stopPropagation();
+ }}
+ />
+ ) : null}
+ <div className="timeline-item-header">
+ <span className="me-2">
+ <span>
+ <UserAvatar
+ username={post.author.username}
+ className="timeline-avatar me-1"
+ />
+ <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.timelineOwnerV2, post.timelineNameV2, post.id)
+ .then(onDeleted, () => {
+ pushAlert({
+ type: "danger",
+ message: "timeline.deletePostFailed",
+ });
+ });
+ }}
+ />
+ </div>
+ );
+};
+
+export default TimelinePostView;
diff --git a/FrontEnd/src/pages/timeline/TimelinePropertyChangeDialog.tsx b/FrontEnd/src/pages/timeline/TimelinePropertyChangeDialog.tsx
new file mode 100644
index 00000000..e26df3eb
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/TimelinePropertyChangeDialog.tsx
@@ -0,0 +1,87 @@
+import * as React from "react";
+
+import {
+ getHttpTimelineClient,
+ HttpTimelineInfo,
+ HttpTimelinePatchRequest,
+ kTimelineVisibilities,
+ TimelineVisibility,
+} from "@/http/timeline";
+
+import OperationDialog from "@/views/common/dialog/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"}
+ inputs={{
+ scheme: {
+ inputs: [
+ {
+ key: "title",
+ type: "text",
+ label: "timeline.dialogChangeProperty.titleField",
+ },
+ {
+ key: "visibility",
+ type: "select",
+ label: "timeline.dialogChangeProperty.visibility",
+ options: kTimelineVisibilities.map((v) => ({
+ label: labelMap[v],
+ value: v,
+ })),
+ },
+ {
+ key: "description",
+ type: "text",
+ label: "timeline.dialogChangeProperty.description",
+ },
+ ],
+ },
+ dataInit: {
+ values: {
+ title: timeline.title,
+ visibility: timeline.visibility,
+ description: timeline.description,
+ },
+ },
+ }}
+ open={props.open}
+ onClose={props.close}
+ onProcess={({ title, visibility, description }) => {
+ const req: HttpTimelinePatchRequest = {};
+ if (title !== timeline.title) {
+ req.title = title as string;
+ }
+ if (visibility !== timeline.visibility) {
+ req.visibility = visibility as TimelineVisibility;
+ }
+ if (description !== timeline.description) {
+ req.description = description as string;
+ }
+ return getHttpTimelineClient()
+ .patchTimeline(timeline.owner.username, timeline.nameV2, req)
+ .then(onChange);
+ }}
+ />
+ );
+};
+
+export default TimelinePropertyChangeDialog;
diff --git a/FrontEnd/src/pages/timeline/index.tsx b/FrontEnd/src/pages/timeline/index.tsx
new file mode 100644
index 00000000..1dffdcc1
--- /dev/null
+++ b/FrontEnd/src/pages/timeline/index.tsx
@@ -0,0 +1,23 @@
+import * as React from "react";
+import { useParams } from "react-router-dom";
+
+import { UiLogicError } from "@/common";
+
+import Timeline from "./Timeline";
+
+const TimelinePage: React.FC = () => {
+ const { owner, timeline: timelineNameParam } = useParams();
+
+ if (owner == null || owner == "")
+ throw new UiLogicError("Route param owner is not set.");
+
+ const timeline = timelineNameParam || "self";
+
+ return (
+ <div className="container">
+ <Timeline timelineOwner={owner} timelineName={timeline} />
+ </div>
+ );
+};
+
+export default TimelinePage;