diff options
Diffstat (limited to 'FrontEnd/src/app/views/timeline-common')
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); }} /> ); |