diff options
Diffstat (limited to 'FrontEnd/src')
31 files changed, 1002 insertions, 409 deletions
diff --git a/FrontEnd/src/app/http/bookmark.ts b/FrontEnd/src/app/http/bookmark.ts new file mode 100644 index 00000000..68de4d73 --- /dev/null +++ b/FrontEnd/src/app/http/bookmark.ts @@ -0,0 +1,70 @@ +import axios from "axios"; + +import { + apiBaseUrl, + convertToNetworkError, + extractResponseData, +} from "./common"; + +import { + HttpTimelineInfo, + processRawTimelineInfo, + RawHttpTimelineInfo, +} from "./timeline"; + +export interface HttpHighlightMoveRequest { + timeline: string; + newPosition: number; +} + +export interface IHttpBookmarkClient { + list(token: string): Promise<HttpTimelineInfo[]>; + put(timeline: string, token: string): Promise<void>; + delete(timeline: string, token: string): Promise<void>; + move(req: HttpHighlightMoveRequest, token: string): Promise<void>; +} + +export class HttpHighlightClient implements IHttpBookmarkClient { + list(token: string): Promise<HttpTimelineInfo[]> { + return axios + .get<RawHttpTimelineInfo[]>(`${apiBaseUrl}/bookmarks?token=${token}`) + .then(extractResponseData) + .then((list) => list.map(processRawTimelineInfo)) + .catch(convertToNetworkError); + } + + put(timeline: string, token: string): Promise<void> { + return axios + .put(`${apiBaseUrl}/bookmarks/${timeline}?token=${token}`) + .catch(convertToNetworkError) + .then(); + } + + delete(timeline: string, token: string): Promise<void> { + return axios + .delete(`${apiBaseUrl}/bookmarks/${timeline}?token=${token}`) + .catch(convertToNetworkError) + .then(); + } + + move(req: HttpHighlightMoveRequest, token: string): Promise<void> { + return axios + .post(`${apiBaseUrl}/bookmarkop/move?token=${token}`, req) + .catch(convertToNetworkError) + .then(); + } +} + +let client: IHttpBookmarkClient = new HttpHighlightClient(); + +export function getHttpBookmarkClient(): IHttpBookmarkClient { + return client; +} + +export function setHttpBookmarkClient( + newClient: IHttpBookmarkClient +): IHttpBookmarkClient { + const old = client; + client = newClient; + return old; +} diff --git a/FrontEnd/src/app/http/highlight.ts b/FrontEnd/src/app/http/highlight.ts new file mode 100644 index 00000000..1f226c19 --- /dev/null +++ b/FrontEnd/src/app/http/highlight.ts @@ -0,0 +1,70 @@ +import axios from "axios"; + +import { + apiBaseUrl, + convertToNetworkError, + extractResponseData, +} from "./common"; + +import { + HttpTimelineInfo, + processRawTimelineInfo, + RawHttpTimelineInfo, +} from "./timeline"; + +export interface HttpHighlightMoveRequest { + timeline: string; + newPosition: number; +} + +export interface IHttpHighlightClient { + list(): Promise<HttpTimelineInfo[]>; + put(timeline: string, token: string): Promise<void>; + delete(timeline: string, token: string): Promise<void>; + move(req: HttpHighlightMoveRequest, token: string): Promise<void>; +} + +export class HttpHighlightClient implements IHttpHighlightClient { + list(): Promise<HttpTimelineInfo[]> { + return axios + .get<RawHttpTimelineInfo[]>(`${apiBaseUrl}/highlights`) + .then(extractResponseData) + .then((list) => list.map(processRawTimelineInfo)) + .catch(convertToNetworkError); + } + + put(timeline: string, token: string): Promise<void> { + return axios + .put(`${apiBaseUrl}/highlights/${timeline}?token=${token}`) + .catch(convertToNetworkError) + .then(); + } + + delete(timeline: string, token: string): Promise<void> { + return axios + .delete(`${apiBaseUrl}/highlights/${timeline}?token=${token}`) + .catch(convertToNetworkError) + .then(); + } + + move(req: HttpHighlightMoveRequest, token: string): Promise<void> { + return axios + .post(`${apiBaseUrl}/highlightop/move?token=${token}`, req) + .catch(convertToNetworkError) + .then(); + } +} + +let client: IHttpHighlightClient = new HttpHighlightClient(); + +export function getHttpHighlightClient(): IHttpHighlightClient { + return client; +} + +export function setHttpHighlightClient( + newClient: IHttpHighlightClient +): IHttpHighlightClient { + const old = client; + client = newClient; + return old; +} diff --git a/FrontEnd/src/app/http/timeline.ts b/FrontEnd/src/app/http/timeline.ts index 71c49852..6be0a183 100644 --- a/FrontEnd/src/app/http/timeline.ts +++ b/FrontEnd/src/app/http/timeline.ts @@ -121,7 +121,7 @@ export class HttpTimelineNameConflictError extends Error { //-------------------- begin: internal model -------------------- -interface RawTimelineInfo { +export interface RawHttpTimelineInfo { uniqueId: string; title: string; name: string; @@ -188,7 +188,9 @@ interface RawTimelinePostPostRequest { //-------------------- end: internal model -------------------- -function processRawTimelineInfo(raw: RawTimelineInfo): HttpTimelineInfo { +export function processRawTimelineInfo( + raw: RawHttpTimelineInfo +): HttpTimelineInfo { return { ...raw, lastModified: new Date(raw.lastModified), @@ -293,7 +295,7 @@ export interface IHttpTimelineClient { export class HttpTimelineClient implements IHttpTimelineClient { listTimeline(query: HttpTimelineListQuery): Promise<HttpTimelineInfo[]> { return axios - .get<RawTimelineInfo[]>( + .get<RawHttpTimelineInfo[]>( applyQueryParameters(`${apiBaseUrl}/timelines`, query) ) .then(extractResponseData) @@ -323,7 +325,7 @@ export class HttpTimelineClient implements IHttpTimelineClient { } ): Promise<HttpTimelineInfo | NotModified> { return axios - .get<RawTimelineInfo>( + .get<RawHttpTimelineInfo>( applyQueryParameters(`${apiBaseUrl}/timelines/${timelineName}`, query) ) .then((res) => { @@ -342,7 +344,7 @@ export class HttpTimelineClient implements IHttpTimelineClient { token: string ): Promise<HttpTimelineInfo> { return axios - .post<RawTimelineInfo>(`${apiBaseUrl}/timelines?token=${token}`, req) + .post<RawHttpTimelineInfo>(`${apiBaseUrl}/timelines?token=${token}`, req) .then(extractResponseData) .then(processRawTimelineInfo) .catch(convertToIfErrorCodeIs(11040101, HttpTimelineNameConflictError)) @@ -355,7 +357,7 @@ export class HttpTimelineClient implements IHttpTimelineClient { token: string ): Promise<HttpTimelineInfo> { return axios - .patch<RawTimelineInfo>( + .patch<RawHttpTimelineInfo>( `${apiBaseUrl}/timelines/${timelineName}?token=${token}`, req ) diff --git a/FrontEnd/src/app/index.sass b/FrontEnd/src/app/index.sass index d5e1ea22..87616998 100644 --- a/FrontEnd/src/app/index.sass +++ b/FrontEnd/src/app/index.sass @@ -1,4 +1,5 @@ @import '~bootstrap/scss/bootstrap'
+@import '~bootstrap-icons/font/bootstrap-icons.css'
@import './views/common/common'
@import './views/common/alert/alert'
@@ -12,9 +13,6 @@ @import './views/admin/admin'
-body
- margin: 0
-
small
line-height: 1.2
@@ -34,10 +32,17 @@ small width: 40px
.icon-button
- font-size: 1.4em
+ font-size: 1.4rem
cursor: pointer
&.large
- font-size: 1.6em
+ font-size: 1.6rem
+
+.flat-button
+ cursor: pointer
+ padding: 0.2em 0.5em
+ border-radius: 0.2em
+ &:hover
+ background-color: $gray-200
.cursor-pointer
cursor: pointer
@@ -50,10 +55,13 @@ textarea .cru-card
@extend .shadow
- @extend .border
@extend .rounded
+ border: 1px solid
border-color: $gray-200
- background: $light
+ background: $gray-100
+ transition: all 0.3s
+ &:hover
+ border-color: $primary
.full-viewport-center-child
position: fixed
@@ -66,6 +74,9 @@ textarea .text-orange
color: $orange
+.text-yellow
+ color: $yellow
+
@each $color, $value in $theme-colors
.text-button
background: transparent
@@ -74,3 +85,6 @@ textarea color: $value
&:hover
color: adjust-color($value, $lightness: +15%)
+
+.touch-action-none
+ touch-action: none
diff --git a/FrontEnd/src/app/locales/en/admin.json b/FrontEnd/src/app/locales/en/admin.json index 098ffb1f..ddb3ffad 100644 --- a/FrontEnd/src/app/locales/en/admin.json +++ b/FrontEnd/src/app/locales/en/admin.json @@ -1,7 +1,7 @@ {
"nav": {
"users": "Users",
- "highlightTimelines": "Highlight Timelines"
+ "more": "More"
},
"create": "Create",
"user": {
diff --git a/FrontEnd/src/app/locales/en/translation.json b/FrontEnd/src/app/locales/en/translation.json index cdb6da37..596b5217 100644 --- a/FrontEnd/src/app/locales/en/translation.json +++ b/FrontEnd/src/app/locales/en/translation.json @@ -1,6 +1,8 @@ { "welcome": "Welcome!", "search": "Search", + "edit": "Edit", + "done": "Done", "loadFailReload": "Load failed, click <1>here</1> to reload.", "serviceWorker": { "availableOffline": "Timeline is now cached in your computer and you can use it offline. 🎉🎉🎉", @@ -20,10 +22,17 @@ "loadImageError": "Failed to load image.", "home": { "go": "Go!", - "allTimeline": "All Timelines", - "joinTimeline": "Joined Timelines", - "ownTimeline": "Owned Timelines", + "highlightTimeline": "Highlight Timelines", + "relatedTimeline": "Timelines Related To You", + "publicTimeline": "Public Timelines", + "bookmarkTimeline": "Bookmark Timelines", "offlinePrompt": "Oh oh, it seems you are offline. Here list some timelines cached locally. You can view them or click <1>here</1> to refresh.", + "message": { + "moveHighlightFail": "Failed to move highlight timeline.", + "deleteHighlightFail": "Failed to delete highlight timeline.", + "moveBookmarkFail": "Failed to move bookmark timeline.", + "deleteBookmarkFail": "Failed to delete bookmark timeline." + }, "createButton": "Create Timeline", "createDialog": { "title": "Create Timeline!", @@ -97,7 +106,9 @@ "title": "Confirm Delete", "prompt": "Are you sure to delete the post? This operation is not recoverable." } - } + }, + "addHighlightSuccess": "Succeeded to add highlight.", + "addBookmarkSuccess": "Succeeded to add bookmark." }, "user": { "username": "username", diff --git a/FrontEnd/src/app/locales/zh/admin.json b/FrontEnd/src/app/locales/zh/admin.json index fed39b2d..edd1cabd 100644 --- a/FrontEnd/src/app/locales/zh/admin.json +++ b/FrontEnd/src/app/locales/zh/admin.json @@ -1,7 +1,7 @@ {
"nav": {
"users": "用户",
- "highlightTimelines": "高光时间线"
+ "more": "更多"
},
"create": "创建",
"user": {
diff --git a/FrontEnd/src/app/locales/zh/translation.json b/FrontEnd/src/app/locales/zh/translation.json index 5d28f694..e15e177e 100644 --- a/FrontEnd/src/app/locales/zh/translation.json +++ b/FrontEnd/src/app/locales/zh/translation.json @@ -1,6 +1,8 @@ { "welcome": "欢迎!", "search": "搜索", + "edit": "编辑", + "done": "完成", "loadFailReload": "加载失败,<1>点击</1>重试。", "serviceWorker": { "availableOffline": "Timeline 已经缓存在本地,你可以离线使用它。🎉🎉🎉", @@ -20,10 +22,17 @@ "loadImageError": "加载图片失败", "home": { "go": "冲!", - "allTimeline": "所有的时间线", - "joinTimeline": "加入的时间线", - "ownTimeline": "拥有的时间线", + "highlightTimeline": "高光时间线", + "relatedTimeline": "关于你的时间线", + "publicTimeline": "公开时间线", + "bookmarkTimeline": "书签时间线", "offlinePrompt": "你好像处于离线状态。以下是一些缓存在本地的时间线。你可以查看它们或者<1>点击</1>重新获取在线信息。", + "message": { + "moveHighlightFail": "移动高光时间线失败。", + "deleteHighlightFail": "删除高光时间线失败。", + "moveBookmarkFail": "移动书签时间线失败。", + "deleteBookmarkFail": "删除书签时间线失败。" + }, "createButton": "创建时间线", "createDialog": { "title": "创建时间线!", @@ -97,7 +106,9 @@ "title": "确认删除", "prompt": "确定删除这个消息?这个操作不可撤销。" } - } + }, + "addHighlightSuccess": "成功添加高光。", + "addBookmarkSuccess": "成功添加书签。" }, "user": { "username": "用户名", diff --git a/FrontEnd/src/app/service-worker.tsx b/FrontEnd/src/app/service-worker.tsx index 3be54bc1..2dc7fb6e 100644 --- a/FrontEnd/src/app/service-worker.tsx +++ b/FrontEnd/src/app/service-worker.tsx @@ -70,7 +70,9 @@ if ("serviceWorker" in navigator) { } }); - const showSkipWaitingPrompt = (): void => { + // Add an event listener to detect when the registered + // service worker has installed but is waiting to activate. + wb.addEventListener("waiting", (): void => { const upgrade = (): void => { isThisTriggerUpgrade = true; if (registration && registration.waiting) { @@ -99,12 +101,7 @@ if ("serviceWorker" in navigator) { dismissTime: "never", type: "success", }); - }; - - // Add an event listener to detect when the registered - // service worker has installed but is waiting to activate. - wb.addEventListener("waiting", showSkipWaitingPrompt); - wb.addEventListener("externalwaiting", showSkipWaitingPrompt); + }); void wb.register().then((reg) => { registration = reg; diff --git a/FrontEnd/src/app/services/user.ts b/FrontEnd/src/app/services/user.ts index 0166bce0..7a60b474 100644 --- a/FrontEnd/src/app/services/user.ts +++ b/FrontEnd/src/app/services/user.ts @@ -43,6 +43,10 @@ export class AuthUser implements User { get hasAllTimelineAdministrationPermission(): boolean { return this.permissions.includes("AllTimelineManagement"); } + + get hasHighlightTimelineAdministrationPermission(): boolean { + return this.permissions.includes("HighlightTimelineManagement"); + } } export interface LoginCredentials { diff --git a/FrontEnd/src/app/views/admin/Admin.tsx b/FrontEnd/src/app/views/admin/Admin.tsx index 446cd36d..0b6d1f05 100644 --- a/FrontEnd/src/app/views/admin/Admin.tsx +++ b/FrontEnd/src/app/views/admin/Admin.tsx @@ -7,7 +7,7 @@ import { AuthUser } from "@/services/user"; import AdminNav from "./AdminNav"; import UserAdmin from "./UserAdmin"; -import HighlightTimelineAdmin from "./HighlightTimelineAdmin"; +import MoreAdmin from "./MoreAdmin"; interface AdminProps { user: AuthUser; @@ -32,8 +32,8 @@ const Admin: React.FC<AdminProps> = ({ user }) => { {(() => { if (name === "users") { return <UserAdmin user={user} />; - } else if (name === "highlighttimelines") { - return <HighlightTimelineAdmin user={user} />; + } else if (name === "more") { + return <MoreAdmin user={user} />; } })()} </Container> diff --git a/FrontEnd/src/app/views/admin/AdminNav.tsx b/FrontEnd/src/app/views/admin/AdminNav.tsx index f376beda..47e2138f 100644 --- a/FrontEnd/src/app/views/admin/AdminNav.tsx +++ b/FrontEnd/src/app/views/admin/AdminNav.tsx @@ -29,12 +29,12 @@ const AdminNav: React.FC = () => { </Nav.Item> <Nav.Item> <Nav.Link - active={name === "highlighttimelines"} + active={name === "more"} onClick={() => { - toggle("highlighttimelines"); + toggle("more"); }} > - {t("admin:nav.highlightTimelines")} + {t("admin:nav.more")} </Nav.Link> </Nav.Item> </Nav> diff --git a/FrontEnd/src/app/views/admin/HighlightTimelineAdmin.tsx b/FrontEnd/src/app/views/admin/HighlightTimelineAdmin.tsx deleted file mode 100644 index 3de7d5a6..00000000 --- a/FrontEnd/src/app/views/admin/HighlightTimelineAdmin.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import React from "react"; - -import { AuthUser } from "@/services/user"; - -export interface HighlightTimelineAdminProps { - user: AuthUser; -} - -const HighlightTimelineAdmin: React.FC<HighlightTimelineAdminProps> = () => { - return <>This is highlight timeline administration page.</>; -}; - -export default HighlightTimelineAdmin; diff --git a/FrontEnd/src/app/views/admin/MoreAdmin.tsx b/FrontEnd/src/app/views/admin/MoreAdmin.tsx new file mode 100644 index 00000000..042789a0 --- /dev/null +++ b/FrontEnd/src/app/views/admin/MoreAdmin.tsx @@ -0,0 +1,13 @@ +import React from "react"; + +import { AuthUser } from "@/services/user"; + +export interface MoreAdminProps { + user: AuthUser; +} + +const MoreAdmin: React.FC<MoreAdminProps> = () => { + return <>More...</>; +}; + +export default MoreAdmin; diff --git a/FrontEnd/src/app/views/admin/UserAdmin.tsx b/FrontEnd/src/app/views/admin/UserAdmin.tsx index 948cbb25..d66abbec 100644 --- a/FrontEnd/src/app/views/admin/UserAdmin.tsx +++ b/FrontEnd/src/app/views/admin/UserAdmin.tsx @@ -1,8 +1,6 @@ import React, { useState, useEffect } from "react"; import clsx from "clsx"; import { ListGroup, Row, Col, Spinner, Button } from "react-bootstrap"; -import InlineSVG from "react-inlinesvg"; -import PencilSquareIcon from "bootstrap-icons/icons/pencil-square.svg"; import OperationDialog, { OperationBoolInputInfo, @@ -62,10 +60,9 @@ const UsernameLabel: React.FC = (props) => { return <span style={{ color: "blue" }}>{props.children}</span>; }; -const UserDeleteDialog: React.FC<DialogProps< - { username: string }, - unknown ->> = ({ open, close, token, data: { username }, onSuccess }) => { +const UserDeleteDialog: React.FC< + DialogProps<{ username: string }, unknown> +> = ({ open, close, token, data: { username }, onSuccess }) => { return ( <OperationDialog open={open} @@ -83,12 +80,14 @@ const UserDeleteDialog: React.FC<DialogProps< ); }; -const UserModifyDialog: React.FC<DialogProps< - { - oldUser: HttpUser; - }, - HttpUser ->> = ({ open, close, token, data: { oldUser }, onSuccess }) => { +const UserModifyDialog: React.FC< + DialogProps< + { + oldUser: HttpUser; + }, + HttpUser + > +> = ({ open, close, token, data: { oldUser }, onSuccess }) => { return ( <OperationDialog open={open} @@ -131,13 +130,15 @@ const UserModifyDialog: React.FC<DialogProps< ); }; -const UserPermissionModifyDialog: React.FC<DialogProps< - { - username: string; - permissions: UserPermission[]; - }, - UserPermission[] ->> = ({ open, close, token, data: { username, permissions }, onSuccess }) => { +const UserPermissionModifyDialog: React.FC< + DialogProps< + { + username: string; + permissions: UserPermission[]; + }, + UserPermission[] + > +> = ({ open, close, token, data: { username, permissions }, onSuccess }) => { const oldPermissionBoolList: boolean[] = kUserPermissionList.map( (permission) => permissions.includes(permission) ); @@ -217,9 +218,8 @@ const UserItem: React.FC<UserItemProps> = ({ user, on }) => { return ( <ListGroup.Item className="admin-user-item"> - <InlineSVG - src={PencilSquareIcon} - className="float-right icon-button text-warning" + <i + className="bi-pencil-square float-right icon-button text-warning" onClick={() => setEditMaskVisible(true)} /> <h4 className="text-primary">{user.username}</h4> diff --git a/FrontEnd/src/app/views/common/OperationDialog.tsx b/FrontEnd/src/app/views/common/OperationDialog.tsx index 77ed851f..5887be48 100644 --- a/FrontEnd/src/app/views/common/OperationDialog.tsx +++ b/FrontEnd/src/app/views/common/OperationDialog.tsx @@ -172,9 +172,7 @@ const OperationDialog = < setStep("process"); props .onProcess( - (values as unknown) as MapOperationInputInfoValueTypeList< - OperationInputInfoList - > + (values as unknown) as MapOperationInputInfoValueTypeList<OperationInputInfoList> ) .then( (d) => { @@ -206,9 +204,7 @@ const OperationDialog = < const { inputValidator } = props; if (inputValidator != null) { const result = inputValidator( - (values as unknown) as MapOperationInputInfoValueTypeList< - OperationInputInfoList - > + (values as unknown) as MapOperationInputInfoValueTypeList<OperationInputInfoList> ); setInputError(result); return isNoError(result); diff --git a/FrontEnd/src/app/views/home/BoardWithUser.tsx b/FrontEnd/src/app/views/home/BoardWithUser.tsx index bbef835a..8afe440b 100644 --- a/FrontEnd/src/app/views/home/BoardWithUser.tsx +++ b/FrontEnd/src/app/views/home/BoardWithUser.tsx @@ -3,98 +3,122 @@ import { Row, Col } from "react-bootstrap"; import { useTranslation } from "react-i18next"; import { AuthUser } from "@/services/user"; -import { TimelineInfo } from "@/services/timeline"; +import { pushAlert } from "@/services/alert"; + +import { getHttpHighlightClient } from "@/http/highlight"; import { getHttpTimelineClient } from "@/http/timeline"; +import { getHttpBookmarkClient } from "@/http/bookmark"; import TimelineBoard from "./TimelineBoard"; -import OfflineBoard from "./OfflineBoard"; const BoardWithUser: React.FC<{ user: AuthUser }> = ({ user }) => { const { t } = useTranslation(); - const [ownTimelines, setOwnTimelines] = React.useState< - TimelineInfo[] | "offline" | "loading" - >("loading"); - const [joinTimelines, setJoinTimelines] = React.useState< - TimelineInfo[] | "offline" | "loading" - >("loading"); - - React.useEffect(() => { - let subscribe = true; - if (ownTimelines === "loading") { - void getHttpTimelineClient() - .listTimeline({ relate: user.username, relateType: "own" }) - .then( - (timelines) => { - if (subscribe) { - setOwnTimelines(timelines); - } - }, - () => { - setOwnTimelines("offline"); - } - ); - } - return () => { - subscribe = false; - }; - }, [user, ownTimelines]); - - React.useEffect(() => { - let subscribe = true; - if (joinTimelines === "loading") { - void getHttpTimelineClient() - .listTimeline({ relate: user.username, relateType: "join" }) - .then( - (timelines) => { - if (subscribe) { - setJoinTimelines(timelines); - } - }, - () => { - setJoinTimelines("offline"); - } - ); - } - return () => { - subscribe = false; - }; - }, [user, joinTimelines]); - return ( - <Row className="my-3 justify-content-center"> - {ownTimelines === "offline" && joinTimelines === "offline" ? ( - <Col sm="8" lg="6"> - <OfflineBoard - onReload={() => { - setOwnTimelines("loading"); - setJoinTimelines("loading"); + <> + <Row className="my-3 justify-content-center"> + <Col xs="12" md="6"> + <TimelineBoard + title={t("home.bookmarkTimeline")} + load={() => getHttpBookmarkClient().list(user.token)} + editHandler={{ + onDelete: (timeline) => { + return getHttpBookmarkClient() + .delete(timeline, user.token) + .catch((e) => { + pushAlert({ + message: { + type: "i18n", + key: "home.message.deleteBookmarkFail", + }, + type: "danger", + }); + throw e; + }); + }, + onMove: (timeline, index, offset) => { + return getHttpBookmarkClient() + .move( + { timeline, newPosition: index + offset + 1 }, // +1 because backend contract: index starts at 1 + user.token + ) + .catch((e) => { + pushAlert({ + message: { + type: "i18n", + key: "home.message.moveBookmarkFail", + }, + type: "danger", + }); + throw e; + }); + }, }} /> </Col> - ) : ( - <> - <Col sm="6" lg="5" className="mb-3 mb-sm-0"> - <TimelineBoard - title={t("home.ownTimeline")} - timelines={ownTimelines} - onReload={() => { - setOwnTimelines("loading"); - }} - /> - </Col> - <Col sm="6" lg="5"> - <TimelineBoard - title={t("home.joinTimeline")} - timelines={joinTimelines} - onReload={() => { - setJoinTimelines("loading"); - }} - /> - </Col> - </> - )} - </Row> + <Col xs="12" md="6" className="my-3 my-md-0"> + <TimelineBoard + title={t("home.relatedTimeline")} + load={() => + getHttpTimelineClient().listTimeline({ relate: user.username }) + } + /> + </Col> + </Row> + <Row className="my-3 justify-content-center"> + <Col xs="12" md="6"> + <TimelineBoard + title={t("home.highlightTimeline")} + load={() => getHttpHighlightClient().list()} + editHandler={ + user.hasHighlightTimelineAdministrationPermission + ? { + onDelete: (timeline) => { + return getHttpHighlightClient() + .delete(timeline, user.token) + .catch((e) => { + pushAlert({ + message: { + type: "i18n", + key: "home.message.deleteHighlightFail", + }, + type: "danger", + }); + throw e; + }); + }, + onMove: (timeline, index, offset) => { + return getHttpHighlightClient() + .move( + { timeline, newPosition: index + offset + 1 }, // +1 because backend contract: index starts at 1 + user.token + ) + .catch((e) => { + pushAlert({ + message: { + type: "i18n", + key: "home.message.moveHighlightFail", + }, + type: "danger", + }); + throw e; + }); + }, + } + : undefined + } + /> + </Col> + <Col xs="12" md="6" className="my-3 my-md-0"> + <TimelineBoard + title={t("home.publicTimeline")} + load={() => + getHttpTimelineClient().listTimeline({ visibility: "Public" }) + } + /> + </Col> + </Row> + </> ); }; diff --git a/FrontEnd/src/app/views/home/BoardWithoutUser.tsx b/FrontEnd/src/app/views/home/BoardWithoutUser.tsx index 7e30f799..d9c7fcf4 100644 --- a/FrontEnd/src/app/views/home/BoardWithoutUser.tsx +++ b/FrontEnd/src/app/views/home/BoardWithoutUser.tsx @@ -1,58 +1,31 @@ import React from "react"; import { Row, Col } from "react-bootstrap"; +import { useTranslation } from "react-i18next"; -import { TimelineInfo } from "@/services/timeline"; +import { getHttpHighlightClient } from "@/http/highlight"; import { getHttpTimelineClient } from "@/http/timeline"; import TimelineBoard from "./TimelineBoard"; -import OfflineBoard from "./OfflineBoard"; const BoardWithoutUser: React.FC = () => { - const [publicTimelines, setPublicTimelines] = React.useState< - TimelineInfo[] | "offline" | "loading" - >("loading"); - - React.useEffect(() => { - let subscribe = true; - if (publicTimelines === "loading") { - void getHttpTimelineClient() - .listTimeline({ visibility: "Public" }) - .then( - (timelines) => { - if (subscribe) { - setPublicTimelines(timelines); - } - }, - () => { - setPublicTimelines("offline"); - } - ); - } - return () => { - subscribe = false; - }; - }, [publicTimelines]); + const { t } = useTranslation(); return ( <Row className="my-3 justify-content-center"> - {publicTimelines === "offline" ? ( - <Col sm="8" lg="6"> - <OfflineBoard - onReload={() => { - setPublicTimelines("loading"); - }} - /> - </Col> - ) : ( - <Col sm="8" lg="6"> - <TimelineBoard - timelines={publicTimelines} - onReload={() => { - setPublicTimelines("loading"); - }} - /> - </Col> - )} + <Col xs="12" md="6"> + <TimelineBoard + title={t("home.highlightTimeline")} + load={() => getHttpHighlightClient().list()} + /> + </Col> + <Col xs="12" md="6" className="my-3 my-md-0"> + <TimelineBoard + title={t("home.publicTimeline")} + load={() => + getHttpTimelineClient().listTimeline({ visibility: "Public" }) + } + /> + </Col> </Row> ); }; diff --git a/FrontEnd/src/app/views/home/TimelineBoard.tsx b/FrontEnd/src/app/views/home/TimelineBoard.tsx index c2a7e5fe..c3f01aed 100644 --- a/FrontEnd/src/app/views/home/TimelineBoard.tsx +++ b/FrontEnd/src/app/views/home/TimelineBoard.tsx @@ -1,26 +1,254 @@ import React from "react"; import clsx from "clsx"; import { Link } from "react-router-dom"; -import { Trans } from "react-i18next"; +import { Trans, useTranslation } from "react-i18next"; import { Spinner } from "react-bootstrap"; import { TimelineInfo } from "@/services/timeline"; import TimelineLogo from "../common/TimelineLogo"; import UserTimelineLogo from "../common/UserTimelineLogo"; +import { HttpTimelineInfo } from "@/http/timeline"; -export interface TimelineBoardProps { +interface TimelineBoardItemProps { + timeline: HttpTimelineInfo; + // In height. + offset?: number; + // In px. + arbitraryOffset?: number; + // If not null, will disable navigation on click. + actions?: { + onDelete: () => void; + onMove: { + start: (e: React.PointerEvent) => void; + moving: (e: React.PointerEvent) => void; + end: (e: React.PointerEvent) => void; + }; + }; +} + +const TimelineBoardItem: React.FC<TimelineBoardItemProps> = ({ + timeline, + arbitraryOffset, + offset, + actions, +}) => { + const { name, title } = timeline; + const isPersonal = name.startsWith("@"); + const url = isPersonal + ? `/users/${timeline.owner.username}` + : `/timelines/${name}`; + + const content = ( + <> + {isPersonal ? ( + <UserTimelineLogo className="icon" /> + ) : ( + <TimelineLogo className="icon" /> + )} + <span className="title">{title}</span> + <small className="ml-2 text-secondary">{name}</small> + <span className="flex-grow-1"></span> + {actions != null ? ( + <div className="right"> + <i + className="bi-trash icon-button text-danger px-2" + onClick={actions.onDelete} + /> + <i + className="bi-grip-vertical icon-button text-gray px-2 touch-action-none" + onPointerDown={(e) => { + e.currentTarget.setPointerCapture(e.pointerId); + actions.onMove.start(e); + }} + onPointerUp={(e) => { + actions.onMove.end(e); + try { + e.currentTarget.releasePointerCapture(e.pointerId); + } catch (_) { + void null; + } + }} + onPointerMove={actions.onMove.moving} + /> + </div> + ) : null} + </> + ); + + const offsetStyle: React.CSSProperties = { + translate: + arbitraryOffset != null + ? `0 ${arbitraryOffset}px` + : offset != null + ? `0 ${offset * 100}%` + : undefined, + transition: offset != null ? "translate 0.5s" : undefined, + zIndex: arbitraryOffset != null ? 1 : undefined, + }; + + return actions == null ? ( + <Link to={url} className="timeline-board-item"> + {content} + </Link> + ) : ( + <div style={offsetStyle} className="timeline-board-item"> + {content} + </div> + ); +}; + +interface TimelineBoardItemContainerProps { + timelines: TimelineInfo[]; + editHandler?: { + // offset may exceed index range plusing index. + onMove: (timeline: string, index: number, offset: number) => void; + onDelete: (timeline: string) => void; + }; +} + +const TimelineBoardItemContainer: React.FC<TimelineBoardItemContainerProps> = ({ + timelines, + editHandler, +}) => { + const [moveState, setMoveState] = React.useState<null | { + index: number; + offset: number; + startPointY: number; + }>(null); + + return ( + <> + {timelines.map((timeline, index) => { + const height = 48; + + let offset: number | undefined = undefined; + let arbitraryOffset: number | undefined = undefined; + if (moveState != null) { + if (index === moveState.index) { + arbitraryOffset = moveState.offset; + } else { + if (moveState.offset >= 0) { + const offsetCount = Math.round(moveState.offset / height); + if ( + index > moveState.index && + index <= moveState.index + offsetCount + ) { + offset = -1; + } else { + offset = 0; + } + } else { + const offsetCount = Math.round(-moveState.offset / height); + if ( + index < moveState.index && + index >= moveState.index - offsetCount + ) { + offset = 1; + } else { + offset = 0; + } + } + } + } + + return ( + <TimelineBoardItem + key={timeline.name} + timeline={timeline} + offset={offset} + arbitraryOffset={arbitraryOffset} + actions={ + editHandler != null + ? { + onDelete: () => { + editHandler.onDelete(timeline.name); + }, + onMove: { + start: (e) => { + if (moveState != null) return; + setMoveState({ + index, + offset: 0, + startPointY: e.clientY, + }); + }, + moving: (e) => { + if (moveState == null) return; + setMoveState({ + index, + offset: e.clientY - moveState.startPointY, + startPointY: moveState.startPointY, + }); + }, + end: () => { + if (moveState != null) { + const offsetCount = Math.round( + moveState.offset / height + ); + editHandler.onMove( + timeline.name, + moveState.index, + offsetCount + ); + } + setMoveState(null); + }, + }, + } + : undefined + } + /> + ); + })} + </> + ); +}; + +interface TimelineBoardUIProps { title?: string; timelines: TimelineInfo[] | "offline" | "loading"; onReload: () => void; className?: string; + editHandler?: { + onMove: (timeline: string, index: number, offset: number) => void; + onDelete: (timeline: string) => void; + }; } -const TimelineBoard: React.FC<TimelineBoardProps> = (props) => { - const { title, timelines, className } = props; +const TimelineBoardUI: React.FC<TimelineBoardUIProps> = (props) => { + const { title, timelines, className, editHandler } = props; + + const { t } = useTranslation(); + + const editable = editHandler != null; + + const [editing, setEditing] = React.useState<boolean>(false); return ( <div className={clsx("timeline-board", className)}> - {title != null && <h3 className="text-center">{title}</h3>} + <div className="timeline-board-header"> + {title != null && <h3>{title}</h3>} + {editable && + (editing ? ( + <div + className="flat-button text-primary" + onClick={() => { + setEditing(false); + }} + > + {t("done")} + </div> + ) : ( + <div + className="flat-button text-primary" + onClick={() => { + setEditing(true); + }} + > + {t("edit")} + </div> + ))} + </div> {(() => { if (timelines === "loading") { return ( @@ -47,28 +275,107 @@ const TimelineBoard: React.FC<TimelineBoardProps> = (props) => { </div> ); } else { - return timelines.map((timeline) => { - const { name, title } = timeline; - const isPersonal = name.startsWith("@"); - const url = isPersonal - ? `/users/${timeline.owner.username}` - : `/timelines/${name}`; - return ( - <Link key={name} to={url} className="timeline-board-item"> - {isPersonal ? ( - <UserTimelineLogo className="icon" /> - ) : ( - <TimelineLogo className="icon" /> - )} - {title} - <small className="ml-2 text-secondary">{name}</small> - </Link> - ); - }); + return ( + <TimelineBoardItemContainer + timelines={timelines} + editHandler={ + editHandler && editing + ? { + onDelete: editHandler.onDelete, + onMove: (timeline, index, offset) => { + if (index + offset >= timelines.length) { + offset = timelines.length - index - 1; + } else if (index + offset < 0) { + offset = -index; + } + editHandler.onMove(timeline, index, offset); + }, + } + : undefined + } + /> + ); } })()} </div> ); }; +export interface TimelineBoardProps { + title?: string; + className?: string; + load: () => Promise<TimelineInfo[]>; + editHandler?: { + onMove: (timeline: string, index: number, offset: number) => Promise<void>; + onDelete: (timeline: string) => Promise<void>; + }; +} + +const TimelineBoard: React.FC<TimelineBoardProps> = ({ + className, + title, + load, + editHandler, +}) => { + const [timelines, setTimelines] = React.useState< + TimelineInfo[] | "offline" | "loading" + >("loading"); + + React.useEffect(() => { + let subscribe = true; + if (timelines === "loading") { + void load().then( + (timelines) => { + if (subscribe) { + setTimelines(timelines); + } + }, + () => { + setTimelines("offline"); + } + ); + } + return () => { + subscribe = false; + }; + }, [load, timelines]); + + return ( + <TimelineBoardUI + title={title} + className={className} + timelines={timelines} + onReload={() => { + setTimelines("loading"); + }} + editHandler={ + typeof timelines === "object" && editHandler != null + ? { + onMove: (timeline, index, offset) => { + const newTimelines = timelines.slice(); + const [t] = newTimelines.splice(index, 1); + newTimelines.splice(index + offset, 0, t); + setTimelines(newTimelines); + editHandler.onMove(timeline, index, offset).then(null, () => { + setTimelines(timelines); + }); + }, + onDelete: (timeline) => { + const newTimelines = timelines.slice(); + newTimelines.splice( + timelines.findIndex((t) => t.name === timeline), + 1 + ); + setTimelines(newTimelines); + editHandler.onDelete(timeline).then(null, () => { + setTimelines(timelines); + }); + }, + } + : undefined + } + /> + ); +}; + export default TimelineBoard; diff --git a/FrontEnd/src/app/views/home/TimelineCreateDialog.tsx b/FrontEnd/src/app/views/home/TimelineCreateDialog.tsx index 12bbfb54..5dcba612 100644 --- a/FrontEnd/src/app/views/home/TimelineCreateDialog.tsx +++ b/FrontEnd/src/app/views/home/TimelineCreateDialog.tsx @@ -1,7 +1,11 @@ import React from "react"; import { useHistory } from "react-router"; -import { validateTimelineName, timelineService } from "@/services/timeline"; +import { + validateTimelineName, + timelineService, + TimelineInfo, +} from "@/services/timeline"; import OperationDialog from "../common/OperationDialog"; interface TimelineCreateDialogProps { @@ -12,8 +16,6 @@ interface TimelineCreateDialogProps { const TimelineCreateDialog: React.FC<TimelineCreateDialogProps> = (props) => { const history = useHistory(); - let nameSaved: string; - return ( <OperationDialog open={props.open} @@ -40,11 +42,11 @@ const TimelineCreateDialog: React.FC<TimelineCreateDialogProps> = (props) => { return null; } }} - onProcess={([name]) => { + onProcess={([name]): Promise<TimelineInfo> => { return timelineService.createTimeline(name).toPromise(); }} - onSuccessAndClose={() => { - history.push(`timelines/${nameSaved}`); + onSuccessAndClose={(timeline: TimelineInfo) => { + history.push(`timelines/${timeline.name}`); }} failurePrompt={(e) => `${e as string}`} /> diff --git a/FrontEnd/src/app/views/home/home.sass b/FrontEnd/src/app/views/home/home.sass index 0c01019b..4b86f241 100644 --- a/FrontEnd/src/app/views/home/home.sass +++ b/FrontEnd/src/app/views/home/home.sass @@ -4,15 +4,33 @@ @extend .flex-column
@extend .py-3
min-height: 200px
+ height: 100%
+ position: relative
+
+.timeline-board-header
+ @extend .px-3
+ display: flex
+ align-items: center
+ justify-content: space-between
.timeline-board-item
font-size: 1.1em
@extend .px-3
- @extend .py-2
+ height: 48px
transition: background 0.3s
+ display: flex
+ align-items: center
.icon
height: 1.3em
color: black
@extend .mr-2
&:hover
background: $gray-300
+ .right
+ display: flex
+ align-items: center
+ flex-shrink: 0
+ .title
+ white-space: nowrap
+ overflow: hidden
+ text-overflow: ellipsis
diff --git a/FrontEnd/src/app/views/home/index.tsx b/FrontEnd/src/app/views/home/index.tsx index 0d439f36..3c53736d 100644 --- a/FrontEnd/src/app/views/home/index.tsx +++ b/FrontEnd/src/app/views/home/index.tsx @@ -33,7 +33,7 @@ const HomePage: React.FC = () => { return ( <> - <Container fluid> + <Container> <Row className="my-3 justify-content-center"> <Col xs={12} sm={8} lg={6}> <SearchInput diff --git a/FrontEnd/src/app/views/timeline-common/CollapseButton.tsx b/FrontEnd/src/app/views/timeline-common/CollapseButton.tsx index 3c52150f..da54f3fd 100644 --- a/FrontEnd/src/app/views/timeline-common/CollapseButton.tsx +++ b/FrontEnd/src/app/views/timeline-common/CollapseButton.tsx @@ -1,8 +1,5 @@ import React from "react"; import clsx from "clsx"; -import Svg from "react-inlinesvg"; -import arrowsAngleContractIcon from "bootstrap-icons/icons/arrows-angle-contract.svg"; -import arrowsAngleExpandIcon from "bootstrap-icons/icons/arrows-angle-expand.svg"; const CollapseButton: React.FC<{ collapse: boolean; @@ -11,10 +8,13 @@ const CollapseButton: React.FC<{ style?: React.CSSProperties; }> = ({ collapse, onClick, className, style }) => { return ( - <Svg - src={collapse ? arrowsAngleExpandIcon : arrowsAngleContractIcon} + <i onClick={onClick} - className={clsx("text-primary icon-button", className)} + className={clsx( + collapse ? "bi-arrows-angle-expand" : "bi-arrows-angle-contract", + "text-primary icon-button", + className + )} style={style} /> ); diff --git a/FrontEnd/src/app/views/timeline-common/InfoCardTemplate.tsx b/FrontEnd/src/app/views/timeline-common/InfoCardTemplate.tsx deleted file mode 100644 index a8de20aa..00000000 --- a/FrontEnd/src/app/views/timeline-common/InfoCardTemplate.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import React from "react"; -import clsx from "clsx"; - -import { TimelineCardComponentProps } from "../timeline-common/TimelinePageTemplateUI"; -import SyncStatusBadge from "../timeline-common/SyncStatusBadge"; -import CollapseButton from "../timeline-common/CollapseButton"; - -const InfoCardTemplate: React.FC< - Pick< - TimelineCardComponentProps<"">, - "collapse" | "toggleCollapse" | "syncStatus" | "className" - > & { children: React.ReactElement[] } -> = ({ collapse, toggleCollapse, syncStatus, className, children }) => { - 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" }}>{children}</div> - </div> - ); -}; - -export default InfoCardTemplate; diff --git a/FrontEnd/src/app/views/timeline-common/TimelineCardTemplate.tsx b/FrontEnd/src/app/views/timeline-common/TimelineCardTemplate.tsx new file mode 100644 index 00000000..ece1942f --- /dev/null +++ b/FrontEnd/src/app/views/timeline-common/TimelineCardTemplate.tsx @@ -0,0 +1,105 @@ +import React from "react"; +import clsx from "clsx"; +import { useTranslation } from "react-i18next"; +import { Dropdown, Button } from "react-bootstrap"; + +import { timelineVisibilityTooltipTranslationMap } from "@/services/timeline"; + +import { TimelineCardComponentProps } from "../timeline-common/TimelinePageTemplateUI"; +import SyncStatusBadge from "../timeline-common/SyncStatusBadge"; +import CollapseButton from "../timeline-common/CollapseButton"; + +export interface TimelineCardTemplateProps + extends Omit<TimelineCardComponentProps<"">, "onManage" | "onMember"> { + infoArea: React.ReactElement; + manageArea: + | { type: "member"; onMember: () => void } + | { + type: "manage"; + items: ( + | { + type: "button"; + text: string; + color?: string; + onClick: () => void; + } + | { type: "divider" } + )[]; + }; +} + +function TimelineCardTemplate({ + timeline, + collapse, + infoArea, + manageArea, + onBookmark, + onHighlight, + toggleCollapse, + syncStatus, + className, +}: TimelineCardTemplateProps): React.ReactElement | null { + const { t } = useTranslation(); + + 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" }}> + {infoArea} + <p className="mb-0">{timeline.description}</p> + <small className="mt-1 d-block"> + {t(timelineVisibilityTooltipTranslationMap[timeline.visibility])} + </small> + <div className="text-right mt-2"> + {onHighlight != null ? ( + <i + className="bi-star icon-button text-yellow mr-3" + onClick={onHighlight} + /> + ) : null} + {onBookmark != null ? ( + <i + className="bi-bookmark icon-button text-yellow mr-3" + onClick={onBookmark} + /> + ) : null} + {manageArea.type === "manage" ? ( + <Dropdown className="d-inline-block"> + <Dropdown.Toggle variant="outline-primary"> + {t("timeline.manage")} + </Dropdown.Toggle> + <Dropdown.Menu> + {manageArea.items.map((item, index) => { + if (item.type === "divider") { + return <Dropdown.Divider key={index} />; + } else { + return ( + <Dropdown.Item + key={index} + onClick={item.onClick} + className={ + item.color != null ? "text-" + item.color : undefined + } + > + {t(item.text)} + </Dropdown.Item> + ); + } + })} + </Dropdown.Menu> + </Dropdown> + ) : ( + <Button variant="outline-primary" onClick={manageArea.onMember}> + {t("timeline.memberButton")} + </Button> + )} + </div> + </div> + </div> + ); +} + +export default TimelineCardTemplate; diff --git a/FrontEnd/src/app/views/timeline-common/TimelineItem.tsx b/FrontEnd/src/app/views/timeline-common/TimelineItem.tsx index 408c49a1..233c81bd 100644 --- a/FrontEnd/src/app/views/timeline-common/TimelineItem.tsx +++ b/FrontEnd/src/app/views/timeline-common/TimelineItem.tsx @@ -2,9 +2,6 @@ import React from "react"; import clsx from "clsx"; import { Link } from "react-router-dom"; import { useTranslation } from "react-i18next"; -import Svg from "react-inlinesvg"; -import chevronDownIcon from "bootstrap-icons/icons/chevron-down.svg"; -import trashIcon from "bootstrap-icons/icons/trash.svg"; import { Modal, Button } from "react-bootstrap"; import { useAvatar } from "@/services/user"; @@ -98,9 +95,8 @@ const TimelineItem: React.FC<TimelineItemProps> = (props) => { <small className="text-dark">{props.post.author.nickname}</small> </span> {more != null ? ( - <Svg - src={chevronDownIcon} - className="text-info icon-button" + <i + className="bi-chevron-down text-info icon-button" onClick={(e) => { more.toggle(); e.stopPropagation(); @@ -139,9 +135,8 @@ const TimelineItem: React.FC<TimelineItemProps> = (props) => { className="position-absolute position-lt w-100 h-100 mask d-flex justify-content-center align-items-center" onClick={more.toggle} > - <Svg - src={trashIcon} - className="text-danger icon-button large" + <i + className="bi-trash text-danger icon-button large" onClick={(e) => { setDeleteDialog(true); e.stopPropagation(); diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx index 6c57e91d..7f5c8206 100644 --- a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx +++ b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx @@ -11,6 +11,8 @@ import { usePostList, useTimelineInfo, } from "@/services/timeline"; +import { getHttpBookmarkClient } from "@/http/bookmark"; +import { getHttpHighlightClient } from "@/http/highlight"; import { TimelineMemberDialog } from "./TimelineMember"; import TimelinePropertyChangeDialog from "./TimelinePropertyChangeDialog"; @@ -116,6 +118,38 @@ export default function TimelinePageTemplate<TManageItem>( ? onManage : undefined, onMember: () => setDialog("member"), + onBookmark: + user != null + ? () => { + void getHttpBookmarkClient() + .put(name, user.token) + .then(() => { + pushAlert({ + message: { + type: "i18n", + key: "timeline.addBookmarkSuccess", + }, + type: "success", + }); + }); + } + : undefined, + onHighlight: + user != null && user.hasHighlightTimelineAdministrationPermission + ? () => { + void getHttpHighlightClient() + .put(name, user.token) + .then(() => { + pushAlert({ + message: { + type: "i18n", + key: "timeline.addHighlightSuccess", + }, + type: "success", + }); + }); + } + : undefined, }; if (type === "cache") { diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx index f60383dd..20ec6e43 100644 --- a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx +++ b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx @@ -15,6 +15,8 @@ export interface TimelineCardComponentProps<TManageItems> { timeline: TimelineInfo; onManage?: (item: TManageItems | "property") => void; onMember: () => void; + onBookmark?: () => void; + onHighlight?: () => void; className?: string; collapse: boolean; syncStatus: TimelineSyncStatus; @@ -28,6 +30,8 @@ export interface TimelinePageTemplateUIProps<TManageItems> { posts?: TimelinePostInfoEx[]; onManage?: (item: TManageItems | "property") => void; onMember: () => void; + onBookmark?: () => void; + onHighlight?: () => void; onPost?: TimelinePostSendCallback; } | I18nText; @@ -153,6 +157,8 @@ export default function TimelinePageTemplateUI<TManageItems>( timeline={data.timeline} onManage={data.onManage} onMember={data.onMember} + onBookmark={data.onBookmark} + onHighlight={data.onHighlight} syncStatus={syncStatus} collapse={cardCollapse} toggleCollapse={toggleCardCollapse} diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePostEdit.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePostEdit.tsx index dfa2f879..207bf6af 100644 --- a/FrontEnd/src/app/views/timeline-common/TimelinePostEdit.tsx +++ b/FrontEnd/src/app/views/timeline-common/TimelinePostEdit.tsx @@ -1,10 +1,7 @@ import React from "react"; import clsx from "clsx"; import { useTranslation } from "react-i18next"; -import Svg from "react-inlinesvg"; import { Button, Spinner, Row, Col, Form } from "react-bootstrap"; -import textIcon from "bootstrap-icons/icons/card-text.svg"; -import imageIcon from "bootstrap-icons/icons/image.svg"; import { UiLogicError } from "@/common"; @@ -212,10 +209,12 @@ const TimelinePostEdit: React.FC<TimelinePostEditProps> = (props) => { return ( <> <div className="d-block text-center mt-1 mb-2"> - <Svg + <i onLoad={notifyHeightChange} - src={kind === "text" ? imageIcon : textIcon} - className="icon-button" + className={clsx( + kind === "text" ? "bi-image" : "bi-card-text", + "icon-button" + )} onClick={toggleKind} /> </div> diff --git a/FrontEnd/src/app/views/timeline/TimelineInfoCard.tsx b/FrontEnd/src/app/views/timeline/TimelineInfoCard.tsx index 1070c6d7..f4dbb67d 100644 --- a/FrontEnd/src/app/views/timeline/TimelineInfoCard.tsx +++ b/FrontEnd/src/app/views/timeline/TimelineInfoCard.tsx @@ -1,85 +1,73 @@ import React from "react"; -import { useTranslation } from "react-i18next"; -import { Dropdown, Button } from "react-bootstrap"; import { useAvatar } from "@/services/user"; -import { timelineVisibilityTooltipTranslationMap } from "@/services/timeline"; import BlobImage from "../common/BlobImage"; +import TimelineCardTemplate, { + TimelineCardTemplateProps, +} from "../timeline-common/TimelineCardTemplate"; import { TimelineCardComponentProps } from "../timeline-common/TimelinePageTemplateUI"; -import InfoCardTemplate from "../timeline-common/InfoCardTemplate"; export type OrdinaryTimelineManageItem = "delete"; -export type TimelineInfoCardProps = TimelineCardComponentProps< - OrdinaryTimelineManageItem ->; +export type TimelineInfoCardProps = TimelineCardComponentProps<OrdinaryTimelineManageItem>; const TimelineInfoCard: React.FC<TimelineInfoCardProps> = (props) => { - const { - timeline, - collapse, - onMember, - onManage, - syncStatus, - toggleCollapse, - } = props; - - const { t } = useTranslation(); + const { onMember, onManage, ...otherProps } = props; + const { timeline } = props; const avatar = useAvatar(timeline?.owner?.username); return ( - <InfoCardTemplate - className={props.className} - syncStatus={syncStatus} - collapse={collapse} - toggleCollapse={toggleCollapse} - > - <h3 className="text-primary d-inline-block align-middle"> - {timeline.title} - <small className="ml-3 text-secondary">{timeline.name}</small> - </h3> - <div className="align-middle"> - <BlobImage blob={avatar} className="avatar small rounded-circle mr-3" /> - {timeline.owner.nickname} - <small className="ml-3 text-secondary"> - @{timeline.owner.username} - </small> - </div> - <p className="mb-0">{timeline.description}</p> - <small className="mt-1 d-block"> - {t(timelineVisibilityTooltipTranslationMap[timeline.visibility])} - </small> - <div className="text-right mt-2"> - {onManage != null ? ( - <Dropdown> - <Dropdown.Toggle variant="outline-primary"> - {t("timeline.manage")} - </Dropdown.Toggle> - <Dropdown.Menu> - <Dropdown.Item onClick={() => onManage("property")}> - {t("timeline.manageItem.property")} - </Dropdown.Item> - <Dropdown.Item onClick={onMember}> - {t("timeline.manageItem.member")} - </Dropdown.Item> - <Dropdown.Divider /> - <Dropdown.Item - className="text-danger" - onClick={() => onManage("delete")} - > - {t("timeline.manageItem.delete")} - </Dropdown.Item> - </Dropdown.Menu> - </Dropdown> - ) : ( - <Button variant="outline-primary" onClick={onMember}> - {t("timeline.memberButton")} - </Button> - )} - </div> - </InfoCardTemplate> + <TimelineCardTemplate + infoArea={ + <> + <h3 className="text-primary d-inline-block align-middle"> + {timeline.title} + <small className="ml-3 text-secondary">{timeline.name}</small> + </h3> + <div className="align-middle"> + <BlobImage + blob={avatar} + className="avatar small rounded-circle mr-3" + /> + {timeline.owner.nickname} + <small className="ml-3 text-secondary"> + @{timeline.owner.username} + </small> + </div> + </> + } + manageArea={((): TimelineCardTemplateProps["manageArea"] => { + if (onManage == null) { + return { type: "member", onMember }; + } else { + return { + type: "manage", + items: [ + { + type: "button", + text: "timeline.manageItem.property", + onClick: () => onManage("property"), + }, + { + type: "button", + onClick: onMember, + text: "timeline.manageItem.member", + }, + { type: "divider" }, + { + type: "button", + onClick: () => onManage("delete"), + color: "danger", + text: "timeline.manageItem.delete", + }, + ], + }; + } + })()} + {...otherProps} + /> ); }; diff --git a/FrontEnd/src/app/views/user/UserInfoCard.tsx b/FrontEnd/src/app/views/user/UserInfoCard.tsx index 4a0c9e87..f31a939f 100644 --- a/FrontEnd/src/app/views/user/UserInfoCard.tsx +++ b/FrontEnd/src/app/views/user/UserInfoCard.tsx @@ -1,80 +1,73 @@ import React from "react"; -import { useTranslation } from "react-i18next"; -import { Dropdown, Button } from "react-bootstrap"; -import { timelineVisibilityTooltipTranslationMap } from "@/services/timeline"; import { useAvatar } from "@/services/user"; import BlobImage from "../common/BlobImage"; +import TimelineCardTemplate, { + TimelineCardTemplateProps, +} from "../timeline-common/TimelineCardTemplate"; import { TimelineCardComponentProps } from "../timeline-common/TimelinePageTemplateUI"; -import InfoCardTemplate from "../timeline-common/InfoCardTemplate"; export type PersonalTimelineManageItem = "avatar" | "nickname"; -export type UserInfoCardProps = TimelineCardComponentProps< - PersonalTimelineManageItem ->; +export type UserInfoCardProps = TimelineCardComponentProps<PersonalTimelineManageItem>; const UserInfoCard: React.FC<UserInfoCardProps> = (props) => { - const { - timeline, - collapse, - onMember, - onManage, - syncStatus, - toggleCollapse, - } = props; - const { t } = useTranslation(); + const { onMember, onManage, ...otherProps } = props; + const { timeline } = props; const avatar = useAvatar(timeline?.owner?.username); return ( - <InfoCardTemplate - className={props.className} - syncStatus={syncStatus} - collapse={collapse} - toggleCollapse={toggleCollapse} - > - <h3 className="text-primary d-inline-block align-middle"> - {timeline.title} - <small className="ml-3 text-secondary">{timeline.name}</small> - </h3> - <div className="align-middle"> - <BlobImage blob={avatar} className="avatar small rounded-circle mr-3" /> - {timeline.owner.nickname} - </div> - <p className="mb-0">{timeline.description}</p> - <small className="mt-1 d-block"> - {t(timelineVisibilityTooltipTranslationMap[timeline.visibility])} - </small> - <div className="text-right mt-2"> - {onManage != null ? ( - <Dropdown> - <Dropdown.Toggle variant="outline-primary"> - {t("timeline.manage")} - </Dropdown.Toggle> - <Dropdown.Menu> - <Dropdown.Item onClick={() => onManage("nickname")}> - {t("timeline.manageItem.nickname")} - </Dropdown.Item> - <Dropdown.Item onClick={() => onManage("avatar")}> - {t("timeline.manageItem.avatar")} - </Dropdown.Item> - <Dropdown.Item onClick={() => onManage("property")}> - {t("timeline.manageItem.property")} - </Dropdown.Item> - <Dropdown.Item onClick={onMember}> - {t("timeline.manageItem.member")} - </Dropdown.Item> - </Dropdown.Menu> - </Dropdown> - ) : ( - <Button variant="outline-primary" onClick={onMember}> - {t("timeline.memberButton")} - </Button> - )} - </div> - </InfoCardTemplate> + <TimelineCardTemplate + infoArea={ + <> + <h3 className="text-primary d-inline-block align-middle"> + {timeline.title} + <small className="ml-3 text-secondary">{timeline.name}</small> + </h3> + <div className="align-middle"> + <BlobImage + blob={avatar} + className="avatar small rounded-circle mr-3" + /> + {timeline.owner.nickname} + </div> + </> + } + manageArea={((): TimelineCardTemplateProps["manageArea"] => { + if (onManage == null) { + return { type: "member", onMember }; + } else { + return { + type: "manage", + items: [ + { + type: "button", + text: "timeline.manageItem.nickname", + onClick: () => onManage("nickname"), + }, + { + type: "button", + text: "timeline.manageItem.avatar", + onClick: () => onManage("avatar"), + }, + { + type: "button", + text: "timeline.manageItem.property", + onClick: () => onManage("property"), + }, + { + type: "button", + onClick: onMember, + text: "timeline.manageItem.member", + }, + ], + }; + } + })()} + {...otherProps} + /> ); }; |