aboutsummaryrefslogtreecommitdiff
path: root/FrontEnd/src
diff options
context:
space:
mode:
Diffstat (limited to 'FrontEnd/src')
-rw-r--r--FrontEnd/src/app/http/bookmark.ts70
-rw-r--r--FrontEnd/src/app/http/highlight.ts70
-rw-r--r--FrontEnd/src/app/http/timeline.ts14
-rw-r--r--FrontEnd/src/app/index.sass28
-rw-r--r--FrontEnd/src/app/locales/en/admin.json2
-rw-r--r--FrontEnd/src/app/locales/en/translation.json19
-rw-r--r--FrontEnd/src/app/locales/zh/admin.json2
-rw-r--r--FrontEnd/src/app/locales/zh/translation.json19
-rw-r--r--FrontEnd/src/app/service-worker.tsx11
-rw-r--r--FrontEnd/src/app/services/user.ts4
-rw-r--r--FrontEnd/src/app/views/admin/Admin.tsx6
-rw-r--r--FrontEnd/src/app/views/admin/AdminNav.tsx6
-rw-r--r--FrontEnd/src/app/views/admin/HighlightTimelineAdmin.tsx13
-rw-r--r--FrontEnd/src/app/views/admin/MoreAdmin.tsx13
-rw-r--r--FrontEnd/src/app/views/admin/UserAdmin.tsx44
-rw-r--r--FrontEnd/src/app/views/common/OperationDialog.tsx8
-rw-r--r--FrontEnd/src/app/views/home/BoardWithUser.tsx186
-rw-r--r--FrontEnd/src/app/views/home/BoardWithoutUser.tsx61
-rw-r--r--FrontEnd/src/app/views/home/TimelineBoard.tsx353
-rw-r--r--FrontEnd/src/app/views/home/TimelineCreateDialog.tsx14
-rw-r--r--FrontEnd/src/app/views/home/home.sass20
-rw-r--r--FrontEnd/src/app/views/home/index.tsx2
-rw-r--r--FrontEnd/src/app/views/timeline-common/CollapseButton.tsx12
-rw-r--r--FrontEnd/src/app/views/timeline-common/InfoCardTemplate.tsx26
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelineCardTemplate.tsx105
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelineItem.tsx13
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx34
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx6
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelinePostEdit.tsx11
-rw-r--r--FrontEnd/src/app/views/timeline/TimelineInfoCard.tsx122
-rw-r--r--FrontEnd/src/app/views/user/UserInfoCard.tsx117
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}
+ />
);
};