diff options
Diffstat (limited to 'FrontEnd/src/app/views')
25 files changed, 571 insertions, 547 deletions
diff --git a/FrontEnd/src/app/views/admin/UserAdmin.tsx b/FrontEnd/src/app/views/admin/UserAdmin.tsx index fbdfd5a3..369eaf1e 100644 --- a/FrontEnd/src/app/views/admin/UserAdmin.tsx +++ b/FrontEnd/src/app/views/admin/UserAdmin.tsx @@ -6,7 +6,7 @@ import OperationDialog, { OperationBoolInputInfo, } from "../common/OperationDialog"; -import { User, AuthUser } from "@/services/user"; +import { AuthUser } from "@/services/user"; import { getHttpUserClient, HttpUser, @@ -199,7 +199,7 @@ type ContextMenuItem = TModify | TModifyPermission | TDelete; interface UserItemProps { on: { [key in ContextMenuItem]: () => void }; - user: User; + user: HttpUser; } const UserItem: React.FC<UserItemProps> = ({ user, on }) => { @@ -273,7 +273,7 @@ const UserAdmin: React.FC<UserAdminProps> = (props) => { } | { type: TDelete; username: string }; - const [users, setUsers] = useState<User[] | null>(null); + const [users, setUsers] = useState<HttpUser[] | null>(null); const [dialog, setDialog] = useState<DialogInfo>(null); const [usersVersion, setUsersVersion] = useState<number>(0); const updateUsers = (): void => { diff --git a/FrontEnd/src/app/views/common/AppBar.tsx b/FrontEnd/src/app/views/common/AppBar.tsx index d0e39f98..e682a308 100644 --- a/FrontEnd/src/app/views/common/AppBar.tsx +++ b/FrontEnd/src/app/views/common/AppBar.tsx @@ -4,14 +4,13 @@ import { LinkContainer } from "react-router-bootstrap"; import { Navbar, Nav } from "react-bootstrap"; import { NavLink } from "react-router-dom"; -import { useUser, useAvatar } from "@/services/user"; +import { useUser } from "@/services/user"; import TimelineLogo from "./TimelineLogo"; -import BlobImage from "./BlobImage"; +import UserAvatar from "./user/UserAvatar"; const AppBar: React.FC = (_) => { const user = useUser(); - const avatar = useAvatar(user?.username); const { t } = useTranslation(); @@ -70,10 +69,9 @@ const AppBar: React.FC = (_) => { <Nav className="ml-auto mr-2 align-items-center"> {user != null ? ( <LinkContainer to={`/users/${user.username}`}> - <BlobImage + <UserAvatar + username={user.username} className="avatar small rounded-circle bg-white cursor-pointer ml-auto" - onClick={collapse} - blob={avatar} /> </LinkContainer> ) : ( diff --git a/FrontEnd/src/app/views/common/user/UserAvatar.tsx b/FrontEnd/src/app/views/common/user/UserAvatar.tsx index 73273298..9e822528 100644 --- a/FrontEnd/src/app/views/common/user/UserAvatar.tsx +++ b/FrontEnd/src/app/views/common/user/UserAvatar.tsx @@ -1,8 +1,6 @@ import React from "react"; -import { useAvatar } from "@/services/user"; - -import BlobImage from "../BlobImage"; +import { getHttpUserClient } from "@/http/user"; export interface UserAvatarProps extends React.ImgHTMLAttributes<HTMLImageElement> { @@ -10,9 +8,12 @@ export interface UserAvatarProps } const UserAvatar: React.FC<UserAvatarProps> = ({ username, ...otherProps }) => { - const avatar = useAvatar(username); - - return <BlobImage blob={avatar} {...otherProps} />; + return ( + <img + src={getHttpUserClient().generateAvatarUrl(username)} + {...otherProps} + /> + ); }; export default UserAvatar; diff --git a/FrontEnd/src/app/views/home/OfflineBoard.tsx b/FrontEnd/src/app/views/home/OfflineBoard.tsx deleted file mode 100644 index fc05bd74..00000000 --- a/FrontEnd/src/app/views/home/OfflineBoard.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import React from "react"; -import { Link } from "react-router-dom"; -import { Trans } from "react-i18next"; - -import { getAllCachedTimelineNames } from "@/services/timeline"; -import UserTimelineLogo from "../common/UserTimelineLogo"; -import TimelineLogo from "../common/TimelineLogo"; - -export interface OfflineBoardProps { - onReload: () => void; -} - -const OfflineBoard: React.FC<OfflineBoardProps> = ({ onReload }) => { - const [timelines, setTimelines] = React.useState<string[]>([]); - - React.useEffect(() => { - let subscribe = true; - void getAllCachedTimelineNames().then((t) => { - if (subscribe) setTimelines(t); - }); - return () => { - subscribe = false; - }; - }); - - return ( - <> - <Trans i18nKey="home.offlinePrompt"> - 0 - <a - href="#" - onClick={(e) => { - onReload(); - e.preventDefault(); - }} - > - 1 - </a> - 2 - </Trans> - {timelines.map((timeline) => { - const isPersonal = timeline.startsWith("@"); - const url = isPersonal - ? `/users/${timeline.slice(1)}` - : `/timelines/${timeline}`; - return ( - <div key={timeline} className="timeline-board-item"> - {isPersonal ? ( - <UserTimelineLogo className="icon" /> - ) : ( - <TimelineLogo className="icon" /> - )} - <Link to={url}>{timeline}</Link> - </div> - ); - })} - </> - ); -}; - -export default OfflineBoard; diff --git a/FrontEnd/src/app/views/home/TimelineBoard.tsx b/FrontEnd/src/app/views/home/TimelineBoard.tsx index c3f01aed..58988b17 100644 --- a/FrontEnd/src/app/views/home/TimelineBoard.tsx +++ b/FrontEnd/src/app/views/home/TimelineBoard.tsx @@ -4,10 +4,10 @@ import { Link } from "react-router-dom"; import { Trans, useTranslation } from "react-i18next"; import { Spinner } from "react-bootstrap"; -import { TimelineInfo } from "@/services/timeline"; +import { HttpTimelineInfo } from "@/http/timeline"; + import TimelineLogo from "../common/TimelineLogo"; import UserTimelineLogo from "../common/UserTimelineLogo"; -import { HttpTimelineInfo } from "@/http/timeline"; interface TimelineBoardItemProps { timeline: HttpTimelineInfo; @@ -98,7 +98,7 @@ const TimelineBoardItem: React.FC<TimelineBoardItemProps> = ({ }; interface TimelineBoardItemContainerProps { - timelines: TimelineInfo[]; + timelines: HttpTimelineInfo[]; editHandler?: { // offset may exceed index range plusing index. onMove: (timeline: string, index: number, offset: number) => void; @@ -206,7 +206,7 @@ const TimelineBoardItemContainer: React.FC<TimelineBoardItemContainerProps> = ({ interface TimelineBoardUIProps { title?: string; - timelines: TimelineInfo[] | "offline" | "loading"; + timelines: HttpTimelineInfo[] | "offline" | "loading"; onReload: () => void; className?: string; editHandler?: { @@ -304,7 +304,7 @@ const TimelineBoardUI: React.FC<TimelineBoardUIProps> = (props) => { export interface TimelineBoardProps { title?: string; className?: string; - load: () => Promise<TimelineInfo[]>; + load: () => Promise<HttpTimelineInfo[]>; editHandler?: { onMove: (timeline: string, index: number, offset: number) => Promise<void>; onDelete: (timeline: string) => Promise<void>; @@ -318,7 +318,7 @@ const TimelineBoard: React.FC<TimelineBoardProps> = ({ editHandler, }) => { const [timelines, setTimelines] = React.useState< - TimelineInfo[] | "offline" | "loading" + HttpTimelineInfo[] | "offline" | "loading" >("loading"); React.useEffect(() => { diff --git a/FrontEnd/src/app/views/home/TimelineCreateDialog.tsx b/FrontEnd/src/app/views/home/TimelineCreateDialog.tsx index 5dcba612..b4e25ba1 100644 --- a/FrontEnd/src/app/views/home/TimelineCreateDialog.tsx +++ b/FrontEnd/src/app/views/home/TimelineCreateDialog.tsx @@ -1,12 +1,9 @@ import React from "react"; import { useHistory } from "react-router"; -import { - validateTimelineName, - timelineService, - TimelineInfo, -} from "@/services/timeline"; +import { validateTimelineName } from "@/services/timeline"; import OperationDialog from "../common/OperationDialog"; +import { getHttpTimelineClient, HttpTimelineInfo } from "@/http/timeline"; interface TimelineCreateDialogProps { open: boolean; @@ -42,10 +39,10 @@ const TimelineCreateDialog: React.FC<TimelineCreateDialogProps> = (props) => { return null; } }} - onProcess={([name]): Promise<TimelineInfo> => { - return timelineService.createTimeline(name).toPromise(); - }} - onSuccessAndClose={(timeline: TimelineInfo) => { + onProcess={([name]): Promise<HttpTimelineInfo> => + getHttpTimelineClient().postTimeline({ name }) + } + onSuccessAndClose={(timeline: HttpTimelineInfo) => { history.push(`timelines/${timeline.name}`); }} failurePrompt={(e) => `${e as string}`} diff --git a/FrontEnd/src/app/views/search/index.tsx b/FrontEnd/src/app/views/search/index.tsx index 41f1e6b6..8401f26c 100644 --- a/FrontEnd/src/app/views/search/index.tsx +++ b/FrontEnd/src/app/views/search/index.tsx @@ -6,15 +6,14 @@ import { Link } from "react-router-dom"; import { HttpNetworkError } from "@/http/common"; import { getHttpSearchClient } from "@/http/search"; - -import { TimelineInfo } from "@/services/timeline"; +import { HttpTimelineInfo } from "@/http/timeline"; import SearchInput from "../common/SearchInput"; import UserAvatar from "../common/user/UserAvatar"; -const TimelineSearchResultItemView: React.FC<{ timeline: TimelineInfo }> = ({ - timeline, -}) => { +const TimelineSearchResultItemView: React.FC<{ + timeline: HttpTimelineInfo; +}> = ({ timeline }) => { const link = timeline.name.startsWith("@") ? `users/${timeline.owner.username}` : `timelines/${timeline.name}`; @@ -51,7 +50,7 @@ const SearchPage: React.FC = () => { const [searchText, setSearchText] = React.useState<string>(""); const [state, setState] = React.useState< - TimelineInfo[] | "init" | "loading" | "network-error" | "error" + HttpTimelineInfo[] | "init" | "loading" | "network-error" | "error" >("init"); const [forceResearchKey, setForceResearchKey] = React.useState<number>(0); diff --git a/FrontEnd/src/app/views/settings/index.tsx b/FrontEnd/src/app/views/settings/index.tsx index 0a85df83..ccba59b7 100644 --- a/FrontEnd/src/app/views/settings/index.tsx +++ b/FrontEnd/src/app/views/settings/index.tsx @@ -53,8 +53,7 @@ const ChangePasswordDialog: React.FC<ChangePasswordDialogProps> = (props) => { return result; }} onProcess={async ([oldPassword, newPassword]) => { - await userService.changePassword(oldPassword, newPassword).toPromise(); - await userService.logout(); + await userService.changePassword(oldPassword, newPassword); setRedirect(true); }} close={() => { 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); }} /> ); diff --git a/FrontEnd/src/app/views/timeline/TimelineDeleteDialog.tsx b/FrontEnd/src/app/views/timeline/TimelineDeleteDialog.tsx index 0d3199d6..f472c16a 100644 --- a/FrontEnd/src/app/views/timeline/TimelineDeleteDialog.tsx +++ b/FrontEnd/src/app/views/timeline/TimelineDeleteDialog.tsx @@ -2,7 +2,7 @@ import React from "react"; import { useHistory } from "react-router"; import { Trans } from "react-i18next"; -import { timelineService } from "@/services/timeline"; +import { getHttpTimelineClient } from "@/http/timeline"; import OperationDialog from "../common/OperationDialog"; @@ -43,7 +43,7 @@ const TimelineDeleteDialog: React.FC<TimelineDeleteDialog> = (props) => { } }} onProcess={() => { - return timelineService.deleteTimeline(name).toPromise(); + return getHttpTimelineClient().deleteTimeline(name); }} onSuccessAndClose={() => { history.replace("/"); diff --git a/FrontEnd/src/app/views/timeline/TimelineInfoCard.tsx b/FrontEnd/src/app/views/timeline/TimelineInfoCard.tsx index 920f504d..63da6f3c 100644 --- a/FrontEnd/src/app/views/timeline/TimelineInfoCard.tsx +++ b/FrontEnd/src/app/views/timeline/TimelineInfoCard.tsx @@ -1,12 +1,10 @@ import React from "react"; -import { useAvatar } from "@/services/user"; - -import BlobImage from "../common/BlobImage"; import TimelineCardTemplate, { TimelineCardTemplateProps, } from "../timeline-common/TimelineCardTemplate"; import { TimelineCardComponentProps } from "../timeline-common/TimelinePageTemplateUI"; +import UserAvatar from "../common/user/UserAvatar"; export type OrdinaryTimelineManageItem = "delete"; @@ -16,8 +14,6 @@ const TimelineInfoCard: React.FC<TimelineInfoCardProps> = (props) => { const { timeline, operations } = props; const { onManage, onMember } = operations; - const avatar = useAvatar(timeline?.owner?.username); - return ( <TimelineCardTemplate infoArea={ @@ -27,8 +23,8 @@ const TimelineInfoCard: React.FC<TimelineInfoCardProps> = (props) => { <small className="ml-3 text-secondary">{timeline.name}</small> </h3> <div className="align-middle"> - <BlobImage - blob={avatar} + <UserAvatar + username={timeline.owner.username} className="avatar small rounded-circle mr-3" /> {timeline.owner.nickname} diff --git a/FrontEnd/src/app/views/timeline/index.tsx b/FrontEnd/src/app/views/timeline/index.tsx index 225a1a59..8048dd12 100644 --- a/FrontEnd/src/app/views/timeline/index.tsx +++ b/FrontEnd/src/app/views/timeline/index.tsx @@ -7,12 +7,13 @@ import TimelinePageUI from "./TimelinePageUI"; import { OrdinaryTimelineManageItem } from "./TimelineInfoCard"; import TimelineDeleteDialog from "./TimelineDeleteDialog"; -const TimelinePage: React.FC = (_) => { +const TimelinePage: React.FC = () => { const { name } = useParams<{ name: string }>(); const [dialog, setDialog] = React.useState<OrdinaryTimelineManageItem | null>( null ); + const [reloadKey, setReloadKey] = React.useState<number>(0); let dialogElement: React.ReactElement | undefined; if (dialog === "delete") { @@ -28,6 +29,8 @@ const TimelinePage: React.FC = (_) => { UiComponent={TimelinePageUI} onManage={(item) => setDialog(item)} notFoundI18nKey="timeline.timelineNotExist" + reloadKey={reloadKey} + onReload={() => setReloadKey(reloadKey + 1)} /> {dialogElement} </> diff --git a/FrontEnd/src/app/views/user/UserInfoCard.tsx b/FrontEnd/src/app/views/user/UserInfoCard.tsx index 01d2c096..24b7b979 100644 --- a/FrontEnd/src/app/views/user/UserInfoCard.tsx +++ b/FrontEnd/src/app/views/user/UserInfoCard.tsx @@ -1,12 +1,10 @@ import React from "react"; -import { useAvatar } from "@/services/user"; - -import BlobImage from "../common/BlobImage"; import TimelineCardTemplate, { TimelineCardTemplateProps, } from "../timeline-common/TimelineCardTemplate"; import { TimelineCardComponentProps } from "../timeline-common/TimelinePageTemplateUI"; +import UserAvatar from "../common/user/UserAvatar"; export type PersonalTimelineManageItem = "avatar" | "nickname"; @@ -16,8 +14,6 @@ const UserInfoCard: React.FC<UserInfoCardProps> = (props) => { const { timeline, operations } = props; const { onManage, onMember } = operations; - const avatar = useAvatar(timeline?.owner?.username); - return ( <TimelineCardTemplate infoArea={ @@ -27,8 +23,8 @@ const UserInfoCard: React.FC<UserInfoCardProps> = (props) => { <small className="ml-3 text-secondary">{timeline.name}</small> </h3> <div className="align-middle"> - <BlobImage - blob={avatar} + <UserAvatar + username={timeline.owner.username} className="avatar small rounded-circle mr-3" /> {timeline.owner.nickname} diff --git a/FrontEnd/src/app/views/user/index.tsx b/FrontEnd/src/app/views/user/index.tsx index bb986178..9b5acbba 100644 --- a/FrontEnd/src/app/views/user/index.tsx +++ b/FrontEnd/src/app/views/user/index.tsx @@ -1,10 +1,9 @@ import React, { useState } from "react"; import { useParams } from "react-router"; -import { userInfoService } from "@/services/user"; +import { getHttpUserClient } from "@/http/user"; import TimelinePageTemplate from "../timeline-common/TimelinePageTemplate"; - import UserPageUI from "./UserPageUI"; import { PersonalTimelineManageItem } from "./UserInfoCard"; import ChangeNicknameDialog from "./ChangeNicknameDialog"; @@ -15,6 +14,8 @@ const UserPage: React.FC = (_) => { const [dialog, setDialog] = useState<null | PersonalTimelineManageItem>(null); + const [reloadKey, setReloadKey] = React.useState<number>(0); + let dialogElement: React.ReactElement | undefined; const closeDialog = (): void => setDialog(null); @@ -24,9 +25,10 @@ const UserPage: React.FC = (_) => { <ChangeNicknameDialog open close={closeDialog} - onProcess={(newNickname) => - userInfoService.setNickname(username, newNickname) - } + onProcess={async (newNickname) => { + await getHttpUserClient().patch(username, { nickname: newNickname }); + setReloadKey(reloadKey + 1); + }} /> ); } else if (dialog === "avatar") { @@ -34,7 +36,10 @@ const UserPage: React.FC = (_) => { <ChangeAvatarDialog open close={closeDialog} - process={(file) => userInfoService.setAvatar(username, file)} + process={async (file) => { + await getHttpUserClient().putAvatar(username, file); + setReloadKey(reloadKey + 1); + }} /> ); } @@ -46,6 +51,8 @@ const UserPage: React.FC = (_) => { UiComponent={UserPageUI} onManage={(item) => setDialog(item)} notFoundI18nKey="timeline.userNotExist" + reloadKey={reloadKey} + onReload={() => setReloadKey(reloadKey + 1)} /> {dialogElement} </> |