aboutsummaryrefslogtreecommitdiff
path: root/FrontEnd/src/app/views/timeline-common
diff options
context:
space:
mode:
Diffstat (limited to 'FrontEnd/src/app/views/timeline-common')
-rw-r--r--FrontEnd/src/app/views/timeline-common/SyncStatusBadge.tsx58
-rw-r--r--FrontEnd/src/app/views/timeline-common/Timeline.tsx176
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelineCardTemplate.tsx23
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelineDateLabel.tsx (renamed from FrontEnd/src/app/views/timeline-common/TimelineDateItem.tsx)4
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelineMember.tsx43
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx157
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx69
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelinePostContentView.tsx114
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelinePostEdit.tsx122
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelinePostListView.tsx75
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelinePostView.tsx (renamed from FrontEnd/src/app/views/timeline-common/TimelineItem.tsx)77
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx21
12 files changed, 514 insertions, 425 deletions
diff --git a/FrontEnd/src/app/views/timeline-common/SyncStatusBadge.tsx b/FrontEnd/src/app/views/timeline-common/SyncStatusBadge.tsx
deleted file mode 100644
index e67cfb43..00000000
--- a/FrontEnd/src/app/views/timeline-common/SyncStatusBadge.tsx
+++ /dev/null
@@ -1,58 +0,0 @@
-import React from "react";
-import clsx from "clsx";
-import { useTranslation } from "react-i18next";
-
-import { UiLogicError } from "@/common";
-
-export type TimelineSyncStatus = "syncing" | "synced" | "offline";
-
-const SyncStatusBadge: React.FC<{
- status: TimelineSyncStatus;
- style?: React.CSSProperties;
- className?: string;
-}> = ({ status, style, className }) => {
- const { t } = useTranslation();
-
- return (
- <div style={style} className={clsx("timeline-sync-state-badge", className)}>
- {(() => {
- switch (status) {
- case "syncing": {
- return (
- <>
- <span className="timeline-sync-state-badge-pin bg-warning" />
- <span className="text-warning">
- {t("timeline.postSyncState.syncing")}
- </span>
- </>
- );
- }
- case "synced": {
- return (
- <>
- <span className="timeline-sync-state-badge-pin bg-success" />
- <span className="text-success">
- {t("timeline.postSyncState.synced")}
- </span>
- </>
- );
- }
- case "offline": {
- return (
- <>
- <span className="timeline-sync-state-badge-pin bg-danger" />
- <span className="text-danger">
- {t("timeline.postSyncState.offline")}
- </span>
- </>
- );
- }
- default:
- throw new UiLogicError("Unknown sync state.");
- }
- })()}
- </div>
- );
-};
-
-export default SyncStatusBadge;
diff --git a/FrontEnd/src/app/views/timeline-common/Timeline.tsx b/FrontEnd/src/app/views/timeline-common/Timeline.tsx
index 288be141..d41588bb 100644
--- a/FrontEnd/src/app/views/timeline-common/Timeline.tsx
+++ b/FrontEnd/src/app/views/timeline-common/Timeline.tsx
@@ -1,116 +1,98 @@
import React from "react";
-import clsx from "clsx";
import {
- TimelineInfo,
- TimelinePostInfo,
- timelineService,
-} from "@/services/timeline";
-import { useUser } from "@/services/user";
-import { pushAlert } from "@/services/alert";
+ HttpForbiddenError,
+ HttpNetworkError,
+ HttpNotFoundError,
+} from "@/http/common";
+import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline";
-import TimelineItem from "./TimelineItem";
-import TimelineTop from "./TimelineTop";
-import TimelineDateItem from "./TimelineDateItem";
-
-function dateEqual(left: Date, right: Date): boolean {
- return (
- left.getDate() == right.getDate() &&
- left.getMonth() == right.getMonth() &&
- left.getFullYear() == right.getFullYear()
- );
-}
+import TimelinePostListView from "./TimelinePostListView";
export interface TimelineProps {
className?: string;
style?: React.CSSProperties;
- timeline: TimelineInfo;
- posts: TimelinePostInfo[];
+ timelineName: string;
+ reloadKey: number;
+ onReload: () => void;
}
const Timeline: React.FC<TimelineProps> = (props) => {
- const { timeline, posts } = props;
+ const { timelineName, className, style, reloadKey, onReload } = props;
- const user = useUser();
+ const [posts, setPosts] = React.useState<
+ | HttpTimelinePostInfo[]
+ | "loading"
+ | "offline"
+ | "notexist"
+ | "forbid"
+ | "error"
+ >("loading");
- const [showMoreIndex, setShowMoreIndex] = React.useState<number>(-1);
+ React.useEffect(() => {
+ let subscribe = true;
- const groupedPosts = React.useMemo<
- { date: Date; posts: (TimelinePostInfo & { index: number })[] }[]
- >(() => {
- const result: {
- date: Date;
- posts: (TimelinePostInfo & { index: number })[];
- }[] = [];
- let index = 0;
- for (const post of posts) {
- const { time } = post;
- 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 }] });
+ setPosts("loading");
+
+ void getHttpTimelineClient()
+ .listPost(timelineName)
+ .then(
+ (data) => {
+ if (subscribe) setPosts(data);
+ },
+ (error) => {
+ if (error instanceof HttpNetworkError) {
+ setPosts("offline");
+ } else if (error instanceof HttpForbiddenError) {
+ setPosts("forbid");
+ } else if (error instanceof HttpNotFoundError) {
+ setPosts("notexist");
+ } else {
+ console.error(error);
+ setPosts("error");
+ }
}
- }
- index++;
- }
- return result;
- }, [posts]);
+ );
+
+ return () => {
+ subscribe = false;
+ };
+ }, [timelineName, reloadKey]);
- return (
- <div style={props.style} className={clsx("timeline", props.className)}>
- <TimelineTop height="56px" />
- {groupedPosts.map((group) => {
- return (
- <>
- <TimelineDateItem date={group.date} />
- {group.posts.map((post) => {
- const deletable = timelineService.hasModifyPostPermission(
- user,
- timeline,
- post
- );
- return (
- <TimelineItem
- post={post}
- key={post.id}
- current={posts.length - 1 === post.index}
- more={
- deletable
- ? {
- isOpen: showMoreIndex === post.index,
- toggle: () =>
- setShowMoreIndex((old) =>
- old === post.index ? -1 : post.index
- ),
- onDelete: () => {
- timelineService
- .deletePost(timeline.name, post.id)
- .catch(() => {
- pushAlert({
- type: "danger",
- message: {
- type: "i18n",
- key: "timeline.deletePostFailed",
- },
- });
- });
- },
- }
- : undefined
- }
- onClick={() => setShowMoreIndex(-1)}
- />
- );
- })}
- </>
- );
- })}
- </div>
- );
+ switch (posts) {
+ case "loading":
+ return (
+ <div className={className} style={style}>
+ Loading.
+ </div>
+ );
+ 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 <TimelinePostListView posts={posts} onReload={onReload} />;
+ }
};
export default Timeline;
diff --git a/FrontEnd/src/app/views/timeline-common/TimelineCardTemplate.tsx b/FrontEnd/src/app/views/timeline-common/TimelineCardTemplate.tsx
index b9f296c5..d6eaa16c 100644
--- a/FrontEnd/src/app/views/timeline-common/TimelineCardTemplate.tsx
+++ b/FrontEnd/src/app/views/timeline-common/TimelineCardTemplate.tsx
@@ -3,16 +3,15 @@ import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { Dropdown, Button } from "react-bootstrap";
-import {
- timelineService,
- timelineVisibilityTooltipTranslationMap,
-} from "@/services/timeline";
+import { getHttpHighlightClient } from "@/http/highlight";
+import { getHttpBookmarkClient } from "@/http/bookmark";
-import { TimelineCardComponentProps } from "../timeline-common/TimelinePageTemplateUI";
-import SyncStatusBadge from "../timeline-common/SyncStatusBadge";
-import CollapseButton from "../timeline-common/CollapseButton";
import { useUser } from "@/services/user";
import { pushAlert } from "@/services/alert";
+import { timelineVisibilityTooltipTranslationMap } from "@/services/timeline";
+
+import { TimelineCardComponentProps } from "../timeline-common/TimelinePageTemplateUI";
+import CollapseButton from "../timeline-common/CollapseButton";
export interface TimelineCardTemplateProps
extends Omit<TimelineCardComponentProps<"">, "operations"> {
@@ -39,7 +38,6 @@ function TimelineCardTemplate({
infoArea,
manageArea,
toggleCollapse,
- syncStatus,
className,
}: TimelineCardTemplateProps): React.ReactElement | null {
const { t } = useTranslation();
@@ -49,7 +47,6 @@ function TimelineCardTemplate({
return (
<div className={clsx("cru-card p-2 clearfix", className)}>
<div className="float-right d-flex align-items-center">
- <SyncStatusBadge status={syncStatus} className="mr-2" />
<CollapseButton collapse={collapse} onClick={toggleCollapse} />
</div>
<div style={{ display: collapse ? "none" : "block" }}>
@@ -67,8 +64,8 @@ function TimelineCardTemplate({
onClick={
user != null && user.hasHighlightTimelineAdministrationPermission
? () => {
- timelineService
- .setHighlight(timeline.name, !timeline.isHighlight)
+ getHttpHighlightClient()
+ [timeline.isHighlight ? "delete" : "put"](timeline.name)
.catch(() => {
pushAlert({
message: {
@@ -91,8 +88,8 @@ function TimelineCardTemplate({
"icon-button text-yellow mr-3"
)}
onClick={() => {
- timelineService
- .setBookmark(timeline.name, !timeline.isBookmark)
+ getHttpBookmarkClient()
+ [timeline.isBookmark ? "delete" : "put"](timeline.name)
.catch(() => {
pushAlert({
message: {
diff --git a/FrontEnd/src/app/views/timeline-common/TimelineDateItem.tsx b/FrontEnd/src/app/views/timeline-common/TimelineDateLabel.tsx
index bcc1530f..ae1b7386 100644
--- a/FrontEnd/src/app/views/timeline-common/TimelineDateItem.tsx
+++ b/FrontEnd/src/app/views/timeline-common/TimelineDateLabel.tsx
@@ -5,7 +5,7 @@ export interface TimelineDateItemProps {
date: Date;
}
-const TimelineDateItem: React.FC<TimelineDateItemProps> = ({ date }) => {
+const TimelineDateLabel: React.FC<TimelineDateItemProps> = ({ date }) => {
return (
<div className="timeline-date-item">
<TimelineLine center={null} />
@@ -16,4 +16,4 @@ const TimelineDateItem: React.FC<TimelineDateItemProps> = ({ date }) => {
);
};
-export default TimelineDateItem;
+export default TimelineDateLabel;
diff --git a/FrontEnd/src/app/views/timeline-common/TimelineMember.tsx b/FrontEnd/src/app/views/timeline-common/TimelineMember.tsx
index 9660b2aa..51512f15 100644
--- a/FrontEnd/src/app/views/timeline-common/TimelineMember.tsx
+++ b/FrontEnd/src/app/views/timeline-common/TimelineMember.tsx
@@ -2,17 +2,17 @@ import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { Container, ListGroup, Modal, Row, Col, Button } from "react-bootstrap";
-import { getHttpSearchClient } from "@/http/search";
+import { convertI18nText, I18nText } from "@/common";
-import { User } from "@/services/user";
-import { TimelineInfo, timelineService } from "@/services/timeline";
+import { HttpUser } from "@/http/user";
+import { getHttpSearchClient } from "@/http/search";
import SearchInput from "../common/SearchInput";
import UserAvatar from "../common/user/UserAvatar";
-import { convertI18nText, I18nText } from "@/common";
+import { getHttpTimelineClient, HttpTimelineInfo } from "@/http/timeline";
const TimelineMemberItem: React.FC<{
- user: User;
+ user: HttpUser;
add?: boolean;
onAction?: (username: string) => void;
}> = ({ user, add, onAction }) => {
@@ -46,16 +46,17 @@ const TimelineMemberItem: React.FC<{
);
};
-const TimelineMemberUserSearch: React.FC<{ timeline: TimelineInfo }> = ({
- timeline,
-}) => {
+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: User[];
+ data: HttpUser[];
}
| { type: "error"; data: I18nText }
| { type: "loading" }
@@ -115,11 +116,12 @@ const TimelineMemberUserSearch: React.FC<{ timeline: TimelineInfo }> = ({
user={user}
add
onAction={() => {
- void timelineService
- .addMember(timeline.name, user.username)
+ void getHttpTimelineClient()
+ .memberPut(timeline.name, user.username)
.then(() => {
setUserSearchText("");
setUserSearchState({ type: "init" });
+ onChange();
});
}}
/>
@@ -140,12 +142,12 @@ const TimelineMemberUserSearch: React.FC<{ timeline: TimelineInfo }> = ({
};
export interface TimelineMemberProps {
- timeline: TimelineInfo;
- editable: boolean;
+ timeline: HttpTimelineInfo;
+ onChange: () => void;
}
const TimelineMember: React.FC<TimelineMemberProps> = (props) => {
- const { timeline, editable } = props;
+ const { timeline, onChange } = props;
const members = [timeline.owner, ...timeline.members];
return (
@@ -156,19 +158,20 @@ const TimelineMember: React.FC<TimelineMemberProps> = (props) => {
key={member.username}
user={member}
onAction={
- editable && index !== 0
+ timeline.manageable && index !== 0
? () => {
- void timelineService.removeMember(
- timeline.name,
- member.username
- );
+ void getHttpTimelineClient()
+ .memberDelete(timeline.name, member.username)
+ .then(onChange);
}
: undefined
}
/>
))}
</ListGroup>
- {editable ? <TimelineMemberUserSearch timeline={timeline} /> : null}
+ {timeline.manageable ? (
+ <TimelineMemberUserSearch timeline={timeline} onChange={onChange} />
+ ) : null}
</Container>
);
};
diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx
index 9b76635e..6a8dd63c 100644
--- a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx
+++ b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx
@@ -1,21 +1,13 @@
import React from "react";
import { UiLogicError } from "@/common";
-import { useUser } from "@/services/user";
-import {
- TimelinePostInfo,
- timelineService,
- usePosts,
- useTimeline,
-} from "@/services/timeline";
-import { mergeDataStatus } from "@/services/DataHub2";
+
+import { HttpNetworkError, HttpNotFoundError } from "@/http/common";
+import { getHttpTimelineClient, HttpTimelineInfo } from "@/http/timeline";
import { TimelineMemberDialog } from "./TimelineMember";
import TimelinePropertyChangeDialog from "./TimelinePropertyChangeDialog";
-import {
- TimelinePageTemplateUIOperations,
- TimelinePageTemplateUIProps,
-} from "./TimelinePageTemplateUI";
+import { TimelinePageTemplateUIProps } from "./TimelinePageTemplateUI";
export interface TimelinePageTemplateProps<TManageItem> {
name: string;
@@ -24,102 +16,67 @@ export interface TimelinePageTemplateProps<TManageItem> {
Omit<TimelinePageTemplateUIProps<TManageItem>, "CardComponent">
>;
notFoundI18nKey: string;
+ reloadKey: number;
+ onReload: () => void;
}
export default function TimelinePageTemplate<TManageItem>(
props: TimelinePageTemplateProps<TManageItem>
): React.ReactElement | null {
- const { name } = props;
-
- const service = timelineService;
-
- const user = useUser();
+ const { name, reloadKey, onReload } = props;
const [dialog, setDialog] = React.useState<null | "property" | "member">(
null
);
- const [scrollBottomKey, setScrollBottomKey] = React.useState<number>(0);
+ // TODO: Auto scroll.
+ // const [scrollBottomKey, _setScrollBottomKey] = React.useState<number>(0);
- React.useEffect(() => {
- if (scrollBottomKey > 0) {
- window.scrollTo(0, document.body.scrollHeight);
- }
- }, [scrollBottomKey]);
+ // React.useEffect(() => {
+ // if (scrollBottomKey > 0) {
+ // window.scrollTo(0, document.body.scrollHeight);
+ // }
+ // }, [scrollBottomKey]);
- const timelineAndStatus = useTimeline(name);
- const postsAndState = usePosts(name);
-
- const [
- scrollToBottomNextSyncKey,
- setScrollToBottomNextSyncKey,
- ] = React.useState<number>(0);
-
- const scrollToBottomNextSync = (): void => {
- setScrollToBottomNextSyncKey((old) => old + 1);
- };
+ const [timeline, setTimeline] = React.useState<
+ HttpTimelineInfo | "loading" | "offline" | "notexist" | "error"
+ >("loading");
React.useEffect(() => {
+ setTimeline("loading");
+
let subscribe = true;
- void timelineService.syncPosts(name).then(() => {
- if (subscribe) {
- setScrollBottomKey((old) => old + 1);
- }
- });
+ void getHttpTimelineClient()
+ .getTimeline(name)
+ .then(
+ (data) => {
+ if (subscribe) {
+ setTimeline(data);
+ }
+ },
+ (error) => {
+ if (subscribe) {
+ if (error instanceof HttpNetworkError) {
+ setTimeline("offline");
+ } else if (error instanceof HttpNotFoundError) {
+ setTimeline("notexist");
+ } else {
+ console.error(error);
+ setTimeline("error");
+ }
+ }
+ }
+ );
return () => {
subscribe = false;
};
- }, [name, scrollToBottomNextSyncKey]);
-
- const uiTimelineProp = ((): TimelinePageTemplateUIProps<TManageItem>["timeline"] => {
- const { status, data: timeline } = timelineAndStatus;
- if (timeline == null) {
- if (status === "offline") {
- return "offline";
- } else {
- return undefined;
- }
- } else if (timeline === "notexist") {
- return "notexist";
- } else {
- const operations: TimelinePageTemplateUIOperations<TManageItem> = {
- onPost: service.hasPostPermission(user, timeline)
- ? (req) =>
- service.createPost(name, req).then(() => scrollToBottomNextSync())
- : undefined,
- onManage: service.hasManagePermission(user, timeline)
- ? (item) => {
- if (item === "property") {
- setDialog(item);
- } else {
- props.onManage(item);
- }
- }
- : undefined,
- onMember: () => setDialog("member"),
- };
-
- const posts = ((): TimelinePostInfo[] | "forbid" | undefined => {
- const { data: postsInfo } = postsAndState;
- if (postsInfo === "forbid") {
- return "forbid";
- } else if (postsInfo == null || postsInfo === "notexist") {
- return undefined;
- } else {
- return postsInfo.posts;
- }
- })();
+ }, [name, reloadKey]);
- return { ...timeline, operations, posts };
- }
- })();
-
- const timeline = timelineAndStatus?.data;
let dialogElement: React.ReactElement | undefined;
const closeDialog = (): void => setDialog(null);
if (dialog === "property") {
- if (timeline == null || timeline === "notexist") {
+ if (typeof timeline !== "object") {
throw new UiLogicError(
"Timeline is null but attempt to open change property dialog."
);
@@ -130,11 +87,11 @@ export default function TimelinePageTemplate<TManageItem>(
open
close={closeDialog}
timeline={timeline}
- onProcess={(req) => service.changeTimelineProperty(name, req)}
+ onChange={onReload}
/>
);
} else if (dialog === "member") {
- if (timeline == null || timeline === "notexist") {
+ if (typeof timeline !== "object") {
throw new UiLogicError(
"Timeline is null but attempt to open change property dialog."
);
@@ -145,7 +102,7 @@ export default function TimelinePageTemplate<TManageItem>(
open
onClose={closeDialog}
timeline={timeline}
- editable={service.hasManagePermission(user, timeline)}
+ onChange={onReload}
/>
);
}
@@ -155,11 +112,25 @@ export default function TimelinePageTemplate<TManageItem>(
return (
<>
<UiComponent
- timeline={uiTimelineProp}
- syncStatus={mergeDataStatus([
- timelineAndStatus.status,
- postsAndState.status,
- ])}
+ timeline={
+ typeof timeline === "object"
+ ? {
+ ...timeline,
+ operations: {
+ onManage: timeline.manageable
+ ? (item) => {
+ if (item === "property") {
+ setDialog(item);
+ } else {
+ props.onManage(item);
+ }
+ }
+ : undefined,
+ onMember: () => setDialog("member"),
+ },
+ }
+ : timeline
+ }
notExistMessageI18nKey={props.notFoundI18nKey}
/>
{dialogElement}
diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx
index ed21d6b5..d133bd34 100644
--- a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx
+++ b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx
@@ -3,15 +3,14 @@ import { useTranslation } from "react-i18next";
import { Spinner } from "react-bootstrap";
import { getAlertHost } from "@/services/alert";
-import { TimelineInfo, TimelinePostInfo } from "@/services/timeline";
+
+import { HttpTimelineInfo } from "@/http/timeline";
import Timeline from "./Timeline";
-import TimelinePostEdit, { TimelinePostSendCallback } from "./TimelinePostEdit";
-import { TimelineSyncStatus } from "./SyncStatusBadge";
+import TimelinePostEdit from "./TimelinePostEdit";
export interface TimelineCardComponentProps<TManageItems> {
- timeline: TimelineInfo;
- syncStatus: TimelineSyncStatus;
+ timeline: HttpTimelineInfo;
operations: {
onManage?: (item: TManageItems | "property") => void;
onMember: () => void;
@@ -26,18 +25,17 @@ export interface TimelinePageTemplateUIOperations<TManageItems> {
onMember: () => void;
onBookmark?: () => void;
onHighlight?: () => void;
- onPost?: TimelinePostSendCallback;
}
export interface TimelinePageTemplateUIProps<TManageItems> {
- timeline?:
- | (TimelineInfo & {
+ timeline:
+ | (HttpTimelineInfo & {
operations: TimelinePageTemplateUIOperations<TManageItems>;
- posts?: TimelinePostInfo[] | "forbid";
})
| "notexist"
- | "offline";
- syncStatus: TimelineSyncStatus;
+ | "offline"
+ | "loading"
+ | "error";
notExistMessageI18nKey: string;
CardComponent: React.ComponentType<TimelineCardComponentProps<TManageItems>>;
}
@@ -45,12 +43,15 @@ export interface TimelinePageTemplateUIProps<TManageItems> {
export default function TimelinePageTemplateUI<TManageItems>(
props: TimelinePageTemplateUIProps<TManageItems>
): React.ReactElement | null {
- const { timeline, syncStatus, CardComponent } = props;
+ const { timeline, CardComponent } = props;
const { t } = useTranslation();
const [bottomSpaceHeight, setBottomSpaceHeight] = React.useState<number>(0);
+ const [timelineReloadKey, setTimelineReloadKey] = React.useState<number>(0);
+ const reloadTimeline = (): void => setTimelineReloadKey((old) => old + 1);
+
const onPostEditHeightChange = React.useCallback((height: number): void => {
setBottomSpaceHeight(height);
if (height === 0) {
@@ -93,7 +94,7 @@ export default function TimelinePageTemplateUI<TManageItems>(
let body: React.ReactElement;
- if (timeline == null) {
+ if (timeline == "loading") {
body = (
<div className="full-viewport-center-child">
<Spinner variant="primary" animation="grow" />
@@ -104,37 +105,33 @@ export default function TimelinePageTemplateUI<TManageItems>(
body = <p className="text-danger">Offline!</p>;
} else if (timeline === "notexist") {
body = <p className="text-danger">{t(props.notExistMessageI18nKey)}</p>;
+ } else if (timeline === "error") {
+ // TODO: i18n
+ body = <p className="text-danger">Error!</p>;
} else {
- const { operations, posts } = timeline;
+ const { operations } = timeline;
body = (
<>
<CardComponent
className="timeline-template-card"
timeline={timeline}
operations={operations}
- syncStatus={syncStatus}
collapse={cardCollapse}
toggleCollapse={toggleCardCollapse}
/>
- {posts != null ? (
- posts === "forbid" ? (
- <div>{t("timeline.messageCantSee")}</div>
- ) : (
- <div
- className="timeline-container"
- style={{
- minHeight: `calc(100vh - ${56 + bottomSpaceHeight}px)`,
- }}
- >
- <Timeline timeline={timeline} posts={posts} />
- </div>
- )
- ) : (
- <div className="full-viewport-center-child">
- <Spinner variant="primary" animation="grow" />
- </div>
- )}
- {operations.onPost != null ? (
+ <div
+ className="timeline-container"
+ style={{
+ minHeight: `calc(100vh - ${56 + bottomSpaceHeight}px)`,
+ }}
+ >
+ <Timeline
+ timelineName={timeline.name}
+ reloadKey={timelineReloadKey}
+ onReload={reloadTimeline}
+ />
+ </div>
+ {timeline.postable ? (
<>
<div
style={{ height: bottomSpaceHeight }}
@@ -142,9 +139,9 @@ export default function TimelinePageTemplateUI<TManageItems>(
/>
<TimelinePostEdit
className="fixed-bottom"
- onPost={operations.onPost}
+ timeline={timeline}
onHeightChange={onPostEditHeightChange}
- timelineUniqueId={timeline.uniqueId}
+ onPosted={reloadTimeline}
/>
</>
) : null}
diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePostContentView.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePostContentView.tsx
new file mode 100644
index 00000000..69954040
--- /dev/null
+++ b/FrontEnd/src/app/views/timeline-common/TimelinePostContentView.tsx
@@ -0,0 +1,114 @@
+import React from "react";
+import { Spinner } from "react-bootstrap";
+
+import { HttpNetworkError } from "@/http/common";
+import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline";
+
+import { useUser } from "@/services/user";
+
+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);
+
+ 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]);
+
+ if (error != null) {
+ // TODO: i18n
+ return (
+ <div className={className} style={style}>
+ Error!
+ </div>
+ );
+ } else if (text == null) {
+ return <Spinner variant="primary" animation="grow" />;
+ } 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={className}
+ style={style}
+ />
+ );
+};
+
+const MarkdownView: React.FC<TimelinePostContentViewProps> = (_props) => {
+ // TODO: Implement this.
+ return <div>Unsupported now!</div>;
+};
+
+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
+ return <div>Error, unknown post type!</div>;
+ }
+};
+
+export default TimelinePostContentView;
diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePostEdit.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePostEdit.tsx
index 207bf6af..7c49e5bb 100644
--- a/FrontEnd/src/app/views/timeline-common/TimelinePostEdit.tsx
+++ b/FrontEnd/src/app/views/timeline-common/TimelinePostEdit.tsx
@@ -5,8 +5,14 @@ import { Button, Spinner, Row, Col, Form } from "react-bootstrap";
import { UiLogicError } from "@/common";
+import {
+ getHttpTimelineClient,
+ HttpTimelineInfo,
+ HttpTimelinePostPostRequestData,
+} from "@/http/timeline";
+
import { pushAlert } from "@/services/alert";
-import { TimelineCreatePostRequest } from "@/services/timeline";
+import { base64 } from "@/http/common";
interface TimelinePostEditImageProps {
onSelect: (blob: Blob | null) => void;
@@ -74,19 +80,15 @@ const TimelinePostEditImage: React.FC<TimelinePostEditImageProps> = (props) => {
);
};
-export type TimelinePostSendCallback = (
- content: TimelineCreatePostRequest
-) => Promise<void>;
-
export interface TimelinePostEditProps {
className?: string;
- onPost: TimelinePostSendCallback;
+ timeline: HttpTimelineInfo;
+ onPosted: () => void;
onHeightChange?: (height: number) => void;
- timelineUniqueId: string;
}
const TimelinePostEdit: React.FC<TimelinePostEditProps> = (props) => {
- const { onPost } = props;
+ const { timeline, onHeightChange, className, onPosted } = props;
const { t } = useTranslation();
@@ -95,7 +97,7 @@ const TimelinePostEdit: React.FC<TimelinePostEditProps> = (props) => {
const [text, setText] = React.useState<string>("");
const [imageBlob, setImageBlob] = React.useState<Blob | null>(null);
- const draftLocalStorageKey = `timeline.${props.timelineUniqueId}.postDraft`;
+ const draftLocalStorageKey = `timeline.${timeline.name}.postDraft`;
React.useEffect(() => {
setText(window.localStorage.getItem(draftLocalStorageKey) ?? "");
@@ -107,18 +109,18 @@ const TimelinePostEdit: React.FC<TimelinePostEditProps> = (props) => {
const containerRef = React.useRef<HTMLDivElement>(null!);
const notifyHeightChange = (): void => {
- if (props.onHeightChange) {
- props.onHeightChange(containerRef.current.clientHeight);
+ if (onHeightChange) {
+ onHeightChange(containerRef.current.clientHeight);
}
};
React.useEffect(() => {
- if (props.onHeightChange) {
- props.onHeightChange(containerRef.current.clientHeight);
+ if (onHeightChange) {
+ onHeightChange(containerRef.current.clientHeight);
}
return () => {
- if (props.onHeightChange) {
- props.onHeightChange(0);
+ if (onHeightChange) {
+ onHeightChange(0);
}
};
});
@@ -128,53 +130,55 @@ const TimelinePostEdit: React.FC<TimelinePostEditProps> = (props) => {
setImageBlob(null);
}, []);
- const onSend = React.useCallback(() => {
+ const onSend = async (): Promise<void> => {
setState("process");
- const req: TimelineCreatePostRequest = (() => {
- switch (kind) {
- case "text":
- return {
- content: {
- type: "text",
- text: text,
- },
- } as TimelineCreatePostRequest;
- case "image":
- if (imageBlob == null) {
- throw new UiLogicError(
- "Content type is image but image blob is null."
- );
- }
- return {
- content: {
- type: "image",
- data: imageBlob,
- },
- } as TimelineCreatePostRequest;
- default:
- throw new UiLogicError("Unknown content type.");
- }
- })();
+ let requestData: HttpTimelinePostPostRequestData;
+ switch (kind) {
+ case "text":
+ requestData = {
+ contentType: "text/plain",
+ data: await base64(new Blob([text])),
+ };
+ break;
+ case "image":
+ if (imageBlob == null) {
+ throw new UiLogicError(
+ "Content type is image but image blob is null."
+ );
+ }
+ requestData = {
+ contentType: imageBlob.type,
+ data: await base64(imageBlob),
+ };
+ break;
+ default:
+ throw new UiLogicError("Unknown content type.");
+ }
- onPost(req).then(
- (_) => {
- if (kind === "text") {
- setText("");
- window.localStorage.removeItem(draftLocalStorageKey);
+ getHttpTimelineClient()
+ .postPost(timeline.name, {
+ dataList: [requestData],
+ })
+ .then(
+ (_) => {
+ if (kind === "text") {
+ setText("");
+ window.localStorage.removeItem(draftLocalStorageKey);
+ }
+ setState("input");
+ setKind("text");
+ onPosted();
+ },
+ (_) => {
+ pushAlert({
+ type: "danger",
+ message: t("timeline.sendPostFailed"),
+ });
+ setState("input");
}
- setState("input");
- setKind("text");
- },
- (_) => {
- pushAlert({
- type: "danger",
- message: t("timeline.sendPostFailed"),
- });
- setState("input");
- }
- );
- }, [onPost, kind, text, imageBlob, t, draftLocalStorageKey]);
+ );
+ };
const onImageSelect = React.useCallback((blob: Blob | null) => {
setImageBlob(blob);
@@ -183,7 +187,7 @@ const TimelinePostEdit: React.FC<TimelinePostEditProps> = (props) => {
return (
<div
ref={containerRef}
- className={clsx("container-fluid bg-light", props.className)}
+ className={clsx("container-fluid bg-light", className)}
>
<Row>
<Col className="px-1 py-1">
diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePostListView.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePostListView.tsx
new file mode 100644
index 00000000..63255619
--- /dev/null
+++ b/FrontEnd/src/app/views/timeline-common/TimelinePostListView.tsx
@@ -0,0 +1,75 @@
+import React from "react";
+import clsx from "clsx";
+
+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 {
+ className?: string;
+ style?: React.CSSProperties;
+ posts: HttpTimelinePostInfo[];
+ onReload: () => void;
+}
+
+const TimelinePostListView: React.FC<TimelinePostListViewProps> = (props) => {
+ const { className, style, 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 (
+ <div style={style} className={clsx("timeline", className)}>
+ {groupedPosts.map((group) => {
+ return (
+ <>
+ <TimelineDateLabel date={group.date} />
+ {group.posts.map((post) => {
+ return (
+ <TimelinePostView
+ key={post.id}
+ post={post}
+ current={posts.length - 1 === post.index}
+ onDeleted={onReload}
+ />
+ );
+ })}
+ </>
+ );
+ })}
+ </div>
+ );
+};
+
+export default TimelinePostListView;
diff --git a/FrontEnd/src/app/views/timeline-common/TimelineItem.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePostView.tsx
index a5b6d04a..7fd98310 100644
--- a/FrontEnd/src/app/views/timeline-common/TimelineItem.tsx
+++ b/FrontEnd/src/app/views/timeline-common/TimelinePostView.tsx
@@ -2,46 +2,45 @@ import React from "react";
import clsx from "clsx";
import { Link } from "react-router-dom";
-import { TimelinePostInfo } from "@/services/timeline";
+import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline";
+
+import { pushAlert } from "@/services/alert";
-import BlobImage from "../common/BlobImage";
import UserAvatar from "../common/user/UserAvatar";
import TimelineLine from "./TimelineLine";
+import TimelinePostContentView from "./TimelinePostContentView";
import TimelinePostDeleteConfirmDialog from "./TimelinePostDeleteConfirmDialog";
-export interface TimelineItemProps {
- post: TimelinePostInfo;
+export interface TimelinePostViewProps {
+ post: HttpTimelinePostInfo;
current?: boolean;
- more?: {
- isOpen: boolean;
- toggle: () => void;
- onDelete: () => void;
- };
- onClick?: () => void;
className?: string;
style?: React.CSSProperties;
+ onDeleted?: () => void;
}
-const TimelineItem: React.FC<TimelineItemProps> = (props) => {
+const TimelinePostView: React.FC<TimelinePostViewProps> = (props) => {
+ const { post, className, style, onDeleted } = props;
const current = props.current === true;
- const { post, more } = props;
-
+ const [
+ operationMaskVisible,
+ setOperationMaskVisible,
+ ] = React.useState<boolean>(false);
const [deleteDialog, setDeleteDialog] = React.useState<boolean>(false);
return (
<div
- className={clsx("timeline-item", current && "current", props.className)}
- onClick={props.onClick}
- style={props.style}
+ className={clsx("timeline-item", current && "current", className)}
+ style={style}
>
<TimelineLine center="node" current={current} />
<div className="timeline-item-card">
- {more != null ? (
+ {post.editable ? (
<i
className="bi-chevron-down text-info icon-button float-right"
onClick={(e) => {
- more.toggle();
+ setOperationMaskVisible(true);
e.stopPropagation();
}}
/>
@@ -57,30 +56,20 @@ const TimelineItem: React.FC<TimelineItemProps> = (props) => {
</Link>
<small className="text-dark mr-2">{post.author.nickname}</small>
<small className="text-secondary white-space-no-wrap">
- {post.time.toLocaleTimeString()}
+ {new Date(post.time).toLocaleTimeString()}
</small>
</span>
</span>
</div>
<div className="timeline-content">
- {(() => {
- const { content } = post;
- if (content.type === "text") {
- return content.text;
- } else {
- return (
- <BlobImage
- blob={content.data}
- className="timeline-content-image"
- />
- );
- }
- })()}
+ <TimelinePostContentView post={post} />
</div>
- {more != null && more.isOpen ? (
+ {operationMaskVisible ? (
<div
className="position-absolute position-lt w-100 h-100 mask d-flex justify-content-center align-items-center"
- onClick={more.toggle}
+ onClick={() => {
+ setOperationMaskVisible(false);
+ }}
>
<i
className="bi-trash text-danger icon-button large"
@@ -92,17 +81,29 @@ const TimelineItem: React.FC<TimelineItemProps> = (props) => {
</div>
) : null}
</div>
- {deleteDialog && more != null ? (
+ {deleteDialog ? (
<TimelinePostDeleteConfirmDialog
onClose={() => {
setDeleteDialog(false);
- more.toggle();
+ setOperationMaskVisible(false);
+ }}
+ onConfirm={() => {
+ void getHttpTimelineClient()
+ .deletePost(post.timelineName, post.id)
+ .then(onDeleted, () => {
+ pushAlert({
+ type: "danger",
+ message: {
+ type: "i18n",
+ key: "timeline.deletePostFailed",
+ },
+ });
+ });
}}
- onConfirm={more.onDelete}
/>
) : null}
</div>
);
};
-export default TimelineItem;
+export default TimelinePostView;
diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx
index ab3285f5..a5628a9a 100644
--- a/FrontEnd/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx
+++ b/FrontEnd/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx
@@ -1,19 +1,20 @@
import React from "react";
import {
- TimelineVisibility,
+ getHttpTimelineClient,
+ HttpTimelineInfo,
+ HttpTimelinePatchRequest,
kTimelineVisibilities,
- TimelineChangePropertyRequest,
- TimelineInfo,
-} from "@/services/timeline";
+ TimelineVisibility,
+} from "@/http/timeline";
import OperationDialog from "../common/OperationDialog";
export interface TimelinePropertyChangeDialogProps {
open: boolean;
close: () => void;
- timeline: TimelineInfo;
- onProcess: (request: TimelineChangePropertyRequest) => Promise<void>;
+ timeline: HttpTimelineInfo;
+ onChange: () => void;
}
const labelMap: { [key in TimelineVisibility]: string } = {
@@ -25,7 +26,7 @@ const labelMap: { [key in TimelineVisibility]: string } = {
const TimelinePropertyChangeDialog: React.FC<TimelinePropertyChangeDialogProps> = (
props
) => {
- const { timeline } = props;
+ const { timeline, onChange } = props;
return (
<OperationDialog
@@ -54,7 +55,7 @@ const TimelinePropertyChangeDialog: React.FC<TimelinePropertyChangeDialogProps>
open={props.open}
close={props.close}
onProcess={([newTitle, newVisibility, newDescription]) => {
- const req: TimelineChangePropertyRequest = {};
+ const req: HttpTimelinePatchRequest = {};
if (newTitle !== timeline.title) {
req.title = newTitle;
}
@@ -64,7 +65,9 @@ const TimelinePropertyChangeDialog: React.FC<TimelinePropertyChangeDialogProps>
if (newDescription !== timeline.description) {
req.description = newDescription;
}
- return props.onProcess(req);
+ return getHttpTimelineClient()
+ .patchTimeline(timeline.name, req)
+ .then(onChange);
}}
/>
);