aboutsummaryrefslogtreecommitdiff
path: root/FrontEnd/src/views
diff options
context:
space:
mode:
Diffstat (limited to 'FrontEnd/src/views')
-rw-r--r--FrontEnd/src/views/about/about.sass4
-rw-r--r--FrontEnd/src/views/about/author-avatar.pngbin0 -> 12038 bytes
-rw-r--r--FrontEnd/src/views/about/github.pngbin0 -> 4268 bytes
-rw-r--r--FrontEnd/src/views/about/index.tsx156
-rw-r--r--FrontEnd/src/views/admin/Admin.tsx48
-rw-r--r--FrontEnd/src/views/admin/AdminNav.tsx44
-rw-r--r--FrontEnd/src/views/admin/MoreAdmin.tsx13
-rw-r--r--FrontEnd/src/views/admin/UserAdmin.tsx396
-rw-r--r--FrontEnd/src/views/admin/admin.sass22
-rw-r--r--FrontEnd/src/views/center/CenterBoards.tsx107
-rw-r--r--FrontEnd/src/views/center/TimelineBoard.tsx370
-rw-r--r--FrontEnd/src/views/center/TimelineCreateDialog.tsx53
-rw-r--r--FrontEnd/src/views/center/center.sass36
-rw-r--r--FrontEnd/src/views/center/index.tsx64
-rw-r--r--FrontEnd/src/views/common/AppBar.tsx80
-rw-r--r--FrontEnd/src/views/common/BlobImage.tsx27
-rw-r--r--FrontEnd/src/views/common/ConfirmDialog.tsx40
-rw-r--r--FrontEnd/src/views/common/FlatButton.tsx36
-rw-r--r--FrontEnd/src/views/common/FullPage.tsx39
-rw-r--r--FrontEnd/src/views/common/ImageCropper.tsx306
-rw-r--r--FrontEnd/src/views/common/LoadFailReload.tsx37
-rw-r--r--FrontEnd/src/views/common/LoadingButton.tsx29
-rw-r--r--FrontEnd/src/views/common/LoadingPage.tsx12
-rw-r--r--FrontEnd/src/views/common/Menu.tsx92
-rw-r--r--FrontEnd/src/views/common/OperationDialog.tsx471
-rw-r--r--FrontEnd/src/views/common/SearchInput.tsx78
-rw-r--r--FrontEnd/src/views/common/Skeleton.tsx30
-rw-r--r--FrontEnd/src/views/common/TabPages.tsx74
-rw-r--r--FrontEnd/src/views/common/TimelineLogo.tsx26
-rw-r--r--FrontEnd/src/views/common/ToggleIconButton.tsx30
-rw-r--r--FrontEnd/src/views/common/UserTimelineLogo.tsx26
-rw-r--r--FrontEnd/src/views/common/alert/AlertHost.tsx106
-rw-r--r--FrontEnd/src/views/common/alert/alert.sass15
-rw-r--r--FrontEnd/src/views/common/common.sass191
-rw-r--r--FrontEnd/src/views/common/user/UserAvatar.tsx19
-rw-r--r--FrontEnd/src/views/home/TimelineListView.tsx101
-rw-r--r--FrontEnd/src/views/home/WebsiteIntroduction.tsx77
-rw-r--r--FrontEnd/src/views/home/home.sass29
-rw-r--r--FrontEnd/src/views/home/index.tsx74
-rw-r--r--FrontEnd/src/views/login/index.tsx151
-rw-r--r--FrontEnd/src/views/login/login.sass2
-rw-r--r--FrontEnd/src/views/search/index.tsx128
-rw-r--r--FrontEnd/src/views/search/search.sass13
-rw-r--r--FrontEnd/src/views/settings/ChangeAvatarDialog.tsx305
-rw-r--r--FrontEnd/src/views/settings/ChangeNicknameDialog.tsx32
-rw-r--r--FrontEnd/src/views/settings/ChangePasswordDialog.tsx68
-rw-r--r--FrontEnd/src/views/settings/index.tsx138
-rw-r--r--FrontEnd/src/views/settings/settings.sass14
-rw-r--r--FrontEnd/src/views/timeline-common/CollapseButton.tsx23
-rw-r--r--FrontEnd/src/views/timeline-common/ConnectionStatusBadge.tsx39
-rw-r--r--FrontEnd/src/views/timeline-common/MarkdownPostEdit.tsx205
-rw-r--r--FrontEnd/src/views/timeline-common/PostPropertyChangeDialog.tsx36
-rw-r--r--FrontEnd/src/views/timeline-common/Timeline.tsx143
-rw-r--r--FrontEnd/src/views/timeline-common/TimelineDateLabel.tsx19
-rw-r--r--FrontEnd/src/views/timeline-common/TimelineLine.tsx51
-rw-r--r--FrontEnd/src/views/timeline-common/TimelineLoading.tsx18
-rw-r--r--FrontEnd/src/views/timeline-common/TimelineMember.tsx195
-rw-r--r--FrontEnd/src/views/timeline-common/TimelinePageCardTemplate.tsx158
-rw-r--r--FrontEnd/src/views/timeline-common/TimelinePageTemplate.tsx190
-rw-r--r--FrontEnd/src/views/timeline-common/TimelinePagedPostListView.tsx43
-rw-r--r--FrontEnd/src/views/timeline-common/TimelinePostContentView.tsx197
-rw-r--r--FrontEnd/src/views/timeline-common/TimelinePostDeleteConfirmDialog.tsx37
-rw-r--r--FrontEnd/src/views/timeline-common/TimelinePostEdit.tsx291
-rw-r--r--FrontEnd/src/views/timeline-common/TimelinePostListView.tsx79
-rw-r--r--FrontEnd/src/views/timeline-common/TimelinePostView.tsx151
-rw-r--r--FrontEnd/src/views/timeline-common/TimelinePropertyChangeDialog.tsx87
-rw-r--r--FrontEnd/src/views/timeline-common/TimelineTop.tsx27
-rw-r--r--FrontEnd/src/views/timeline-common/timeline-common.sass259
-rw-r--r--FrontEnd/src/views/timeline/TimelineCard.tsx74
-rw-r--r--FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx55
-rw-r--r--FrontEnd/src/views/timeline/index.tsx23
-rw-r--r--FrontEnd/src/views/timeline/timeline.sass0
-rw-r--r--FrontEnd/src/views/user/UserCard.tsx51
-rw-r--r--FrontEnd/src/views/user/index.tsx28
-rw-r--r--FrontEnd/src/views/user/user.sass7
75 files changed, 6695 insertions, 0 deletions
diff --git a/FrontEnd/src/views/about/about.sass b/FrontEnd/src/views/about/about.sass
new file mode 100644
index 00000000..f4d00cae
--- /dev/null
+++ b/FrontEnd/src/views/about/about.sass
@@ -0,0 +1,4 @@
+.about-link-icon
+ @extend .mx-2
+ width: 1.2em
+ height: 1.2em
diff --git a/FrontEnd/src/views/about/author-avatar.png b/FrontEnd/src/views/about/author-avatar.png
new file mode 100644
index 00000000..d890d8d0
--- /dev/null
+++ b/FrontEnd/src/views/about/author-avatar.png
Binary files differ
diff --git a/FrontEnd/src/views/about/github.png b/FrontEnd/src/views/about/github.png
new file mode 100644
index 00000000..ea6ff545
--- /dev/null
+++ b/FrontEnd/src/views/about/github.png
Binary files differ
diff --git a/FrontEnd/src/views/about/index.tsx b/FrontEnd/src/views/about/index.tsx
new file mode 100644
index 00000000..a8a53a97
--- /dev/null
+++ b/FrontEnd/src/views/about/index.tsx
@@ -0,0 +1,156 @@
+import React from "react";
+import { useTranslation, Trans } from "react-i18next";
+
+import authorAvatarUrl from "./author-avatar.png";
+import githubLogoUrl from "./github.png";
+
+const frontendCredits: {
+ name: string;
+ url: string;
+}[] = [
+ {
+ name: "reactjs",
+ url: "https://reactjs.org",
+ },
+ {
+ name: "typescript",
+ url: "https://www.typescriptlang.org",
+ },
+ {
+ name: "bootstrap",
+ url: "https://getbootstrap.com",
+ },
+ {
+ name: "react-bootstrap",
+ url: "https://react-bootstrap.github.io",
+ },
+ {
+ name: "webpack",
+ url: "https://webpack.js.org",
+ },
+ {
+ name: "sass",
+ url: "https://sass-lang.com",
+ },
+ {
+ name: "eslint",
+ url: "https://eslint.org",
+ },
+ {
+ name: "prettier",
+ url: "https://prettier.io",
+ },
+ {
+ name: "pepjs",
+ url: "https://github.com/jquery/PEP",
+ },
+];
+
+const backendCredits: {
+ name: string;
+ url: string;
+}[] = [
+ {
+ name: "ASP.NET Core",
+ url: "https://dotnet.microsoft.com/learn/aspnet/what-is-aspnet-core",
+ },
+ { name: "sqlite", url: "https://sqlite.org" },
+ {
+ name: "ImageSharp",
+ url: "https://github.com/SixLabors/ImageSharp",
+ },
+];
+
+const AboutPage: React.FC = () => {
+ const { t } = useTranslation();
+
+ return (
+ <div className="px-2 mb-4">
+ <div className="container mt-4 py-3 cru-card">
+ <h4 id="author-info">{t("about.author.title")}</h4>
+ <div>
+ <div className="d-flex">
+ <img
+ src={authorAvatarUrl}
+ className="align-self-start avatar large rounded-circle"
+ />
+ <div>
+ <p>
+ <small>{t("about.author.fullname")}</small>
+ <span className="text-primary">杨宇千</span>
+ </p>
+ <p>
+ <small>{t("about.author.nickname")}</small>
+ <span className="text-primary">crupest</span>
+ </p>
+ <p>
+ <small>{t("about.author.introduction")}</small>
+ {t("about.author.introductionContent")}
+ </p>
+ </div>
+ </div>
+ <p>
+ <small>{t("about.author.links")}</small>
+ <a
+ href="https://github.com/crupest"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ <img src={githubLogoUrl} className="about-link-icon text-body" />
+ </a>
+ </p>
+ </div>
+ </div>
+ <div className="container mt-4 py-3 cru-card">
+ <h4>{t("about.site.title")}</h4>
+ <p>
+ <Trans i18nKey="about.site.content">
+ 0<span className="text-primary">1</span>2<b>3</b>4
+ <a href="#author-info">5</a>6
+ </Trans>
+ </p>
+ <p>
+ <a
+ href="https://github.com/crupest/Timeline"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ {t("about.site.repo")}
+ </a>
+ </p>
+ </div>
+ <div className="container mt-4 py-3 cru-card">
+ <h4>{t("about.credits.title")}</h4>
+ <p>{t("about.credits.content")}</p>
+ <p>{t("about.credits.frontend")}</p>
+ <ul>
+ {frontendCredits.map((item, index) => {
+ return (
+ <li key={index}>
+ <a href={item.url} target="_blank" rel="noopener noreferrer">
+ {item.name}
+ </a>
+ </li>
+ );
+ })}
+ <li>...</li>
+ </ul>
+ <p>{t("about.credits.backend")}</p>
+ <ul>
+ {backendCredits.map((item, index) => {
+ return (
+ <li key={index}>
+ <a href={item.url} target="_blank" rel="noopener noreferrer">
+ {item.name}
+ </a>
+ </li>
+ );
+ })}
+ <li>...</li>
+ </ul>
+ </div>
+ </div>
+ );
+};
+
+export default AboutPage;
diff --git a/FrontEnd/src/views/admin/Admin.tsx b/FrontEnd/src/views/admin/Admin.tsx
new file mode 100644
index 00000000..0b6d1f05
--- /dev/null
+++ b/FrontEnd/src/views/admin/Admin.tsx
@@ -0,0 +1,48 @@
+import React, { Fragment } from "react";
+import { Redirect, Route, Switch, useRouteMatch, match } from "react-router";
+import { Container } from "react-bootstrap";
+import { useTranslation } from "react-i18next";
+
+import { AuthUser } from "@/services/user";
+
+import AdminNav from "./AdminNav";
+import UserAdmin from "./UserAdmin";
+import MoreAdmin from "./MoreAdmin";
+
+interface AdminProps {
+ user: AuthUser;
+}
+
+const Admin: React.FC<AdminProps> = ({ user }) => {
+ useTranslation("admin");
+
+ const match = useRouteMatch();
+
+ return (
+ <Fragment>
+ <Switch>
+ <Redirect from={match.path} to={`${match.path}/users`} exact />
+ <Route path={`${match.path}/:name`}>
+ {(p) => {
+ const match = p.match as match<{ name: string }>;
+ const name = match.params["name"];
+ return (
+ <Container>
+ <AdminNav />
+ {(() => {
+ if (name === "users") {
+ return <UserAdmin user={user} />;
+ } else if (name === "more") {
+ return <MoreAdmin user={user} />;
+ }
+ })()}
+ </Container>
+ );
+ }}
+ </Route>
+ </Switch>
+ </Fragment>
+ );
+};
+
+export default Admin;
diff --git a/FrontEnd/src/views/admin/AdminNav.tsx b/FrontEnd/src/views/admin/AdminNav.tsx
new file mode 100644
index 00000000..47e2138f
--- /dev/null
+++ b/FrontEnd/src/views/admin/AdminNav.tsx
@@ -0,0 +1,44 @@
+import React from "react";
+import { Nav } from "react-bootstrap";
+import { useTranslation } from "react-i18next";
+import { useHistory, useRouteMatch } from "react-router";
+
+const AdminNav: React.FC = () => {
+ const match = useRouteMatch<{ name: string }>();
+ const history = useHistory();
+
+ const { t } = useTranslation();
+
+ const name = match.params.name;
+
+ function toggle(newTab: string): void {
+ history.push(`/admin/${newTab}`);
+ }
+
+ return (
+ <Nav variant="tabs" className="my-2">
+ <Nav.Item>
+ <Nav.Link
+ active={name === "users"}
+ onClick={() => {
+ toggle("users");
+ }}
+ >
+ {t("admin:nav.users")}
+ </Nav.Link>
+ </Nav.Item>
+ <Nav.Item>
+ <Nav.Link
+ active={name === "more"}
+ onClick={() => {
+ toggle("more");
+ }}
+ >
+ {t("admin:nav.more")}
+ </Nav.Link>
+ </Nav.Item>
+ </Nav>
+ );
+};
+
+export default AdminNav;
diff --git a/FrontEnd/src/views/admin/MoreAdmin.tsx b/FrontEnd/src/views/admin/MoreAdmin.tsx
new file mode 100644
index 00000000..042789a0
--- /dev/null
+++ b/FrontEnd/src/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/views/admin/UserAdmin.tsx b/FrontEnd/src/views/admin/UserAdmin.tsx
new file mode 100644
index 00000000..4e9cd600
--- /dev/null
+++ b/FrontEnd/src/views/admin/UserAdmin.tsx
@@ -0,0 +1,396 @@
+import React, { useState, useEffect } from "react";
+import classnames from "classnames";
+import { ListGroup, Row, Col, Spinner, Button } from "react-bootstrap";
+
+import OperationDialog, {
+ OperationDialogBoolInput,
+} from "../common/OperationDialog";
+
+import { AuthUser } from "@/services/user";
+import {
+ getHttpUserClient,
+ HttpUser,
+ kUserPermissionList,
+ UserPermission,
+} from "http/user";
+import { Trans, useTranslation } from "react-i18next";
+
+interface DialogProps<TData = undefined, TReturn = undefined> {
+ open: boolean;
+ close: () => void;
+ data: TData;
+ onSuccess: (data: TReturn) => void;
+}
+
+const CreateUserDialog: React.FC<DialogProps<undefined, HttpUser>> = ({
+ open,
+ close,
+ onSuccess,
+}) => {
+ return (
+ <OperationDialog
+ title="admin:user.dialog.create.title"
+ themeColor="success"
+ inputPrompt="admin:user.dialog.create.prompt"
+ inputScheme={
+ [
+ { type: "text", label: "admin:user.username" },
+ { type: "text", label: "admin:user.password" },
+ ] as const
+ }
+ onProcess={([username, password]) =>
+ getHttpUserClient().post({
+ username,
+ password,
+ })
+ }
+ close={close}
+ open={open}
+ onSuccessAndClose={onSuccess}
+ />
+ );
+};
+
+const UsernameLabel: React.FC = (props) => {
+ return <span style={{ color: "blue" }}>{props.children}</span>;
+};
+
+const UserDeleteDialog: React.FC<DialogProps<{ username: string }, unknown>> =
+ ({ open, close, data: { username }, onSuccess }) => {
+ return (
+ <OperationDialog
+ open={open}
+ close={close}
+ title="admin:user.dialog.delete.title"
+ themeColor="danger"
+ inputPrompt={() => (
+ <Trans i18nKey="admin:user.dialog.delete.prompt">
+ 0<UsernameLabel>{username}</UsernameLabel>2
+ </Trans>
+ )}
+ onProcess={() => getHttpUserClient().delete(username)}
+ onSuccessAndClose={onSuccess}
+ />
+ );
+ };
+
+const UserModifyDialog: React.FC<
+ DialogProps<
+ {
+ oldUser: HttpUser;
+ },
+ HttpUser
+ >
+> = ({ open, close, data: { oldUser }, onSuccess }) => {
+ return (
+ <OperationDialog
+ open={open}
+ close={close}
+ title="admin:user.dialog.modify.title"
+ themeColor="danger"
+ inputPrompt={() => (
+ <Trans i18nKey="admin:user.dialog.modify.prompt">
+ 0<UsernameLabel>{oldUser.username}</UsernameLabel>2
+ </Trans>
+ )}
+ inputScheme={
+ [
+ {
+ type: "text",
+ label: "admin:user.username",
+ initValue: oldUser.username,
+ },
+ { type: "text", label: "admin:user.password" },
+ {
+ type: "text",
+ label: "admin:user.nickname",
+ initValue: oldUser.nickname,
+ },
+ ] as const
+ }
+ onProcess={([username, password, nickname]) =>
+ getHttpUserClient().patch(oldUser.username, {
+ username: username !== oldUser.username ? username : undefined,
+ password: password !== "" ? password : undefined,
+ nickname: nickname !== oldUser.nickname ? nickname : undefined,
+ })
+ }
+ onSuccessAndClose={onSuccess}
+ />
+ );
+};
+
+const UserPermissionModifyDialog: React.FC<
+ DialogProps<
+ {
+ username: string;
+ permissions: UserPermission[];
+ },
+ UserPermission[]
+ >
+> = ({ open, close, data: { username, permissions }, onSuccess }) => {
+ const oldPermissionBoolList: boolean[] = kUserPermissionList.map(
+ (permission) => permissions.includes(permission)
+ );
+
+ return (
+ <OperationDialog
+ open={open}
+ close={close}
+ title="admin:user.dialog.modifyPermissions.title"
+ themeColor="danger"
+ inputPrompt={() => (
+ <Trans i18nKey="admin:user.dialog.modifyPermissions.prompt">
+ 0<UsernameLabel>{username}</UsernameLabel>2
+ </Trans>
+ )}
+ inputScheme={kUserPermissionList.map<OperationDialogBoolInput>(
+ (permission, index) => ({
+ type: "bool",
+ label: permission,
+ initValue: oldPermissionBoolList[index],
+ })
+ )}
+ onProcess={async (newPermissionBoolList): Promise<boolean[]> => {
+ for (let index = 0; index < kUserPermissionList.length; index++) {
+ const oldValue = oldPermissionBoolList[index];
+ const newValue = newPermissionBoolList[index];
+ const permission = kUserPermissionList[index];
+ if (oldValue === newValue) continue;
+ if (newValue) {
+ await getHttpUserClient().putUserPermission(username, permission);
+ } else {
+ await getHttpUserClient().deleteUserPermission(
+ username,
+ permission
+ );
+ }
+ }
+ return newPermissionBoolList;
+ }}
+ onSuccessAndClose={(newPermissionBoolList: boolean[]) => {
+ const permissions: UserPermission[] = [];
+ for (let index = 0; index < kUserPermissionList.length; index++) {
+ if (newPermissionBoolList[index]) {
+ permissions.push(kUserPermissionList[index]);
+ }
+ }
+ onSuccess(permissions);
+ }}
+ />
+ );
+};
+
+const kModify = "modify";
+const kModifyPermission = "permission";
+const kDelete = "delete";
+
+type TModify = typeof kModify;
+type TModifyPermission = typeof kModifyPermission;
+type TDelete = typeof kDelete;
+
+type ContextMenuItem = TModify | TModifyPermission | TDelete;
+
+interface UserItemProps {
+ on: { [key in ContextMenuItem]: () => void };
+ user: HttpUser;
+}
+
+const UserItem: React.FC<UserItemProps> = ({ user, on }) => {
+ const { t } = useTranslation();
+
+ const [editMaskVisible, setEditMaskVisible] = React.useState<boolean>(false);
+
+ return (
+ <ListGroup.Item className="admin-user-item">
+ <i
+ className="bi-pencil-square float-end icon-button text-warning"
+ onClick={() => setEditMaskVisible(true)}
+ />
+ <h4 className="text-primary">{user.username}</h4>
+ <div className="text-secondary">
+ {t("admin:user.nickname")}
+ {user.nickname}
+ </div>
+ <div className="text-secondary">
+ {t("admin:user.uniqueId")}
+ {user.uniqueId}
+ </div>
+ <div className="text-secondary">
+ {t("admin:user.permissions")}
+ {user.permissions.map((permission) => {
+ return (
+ <span key={permission} className="text-danger">
+ {permission}{" "}
+ </span>
+ );
+ })}
+ </div>
+ <div
+ className={classnames("edit-mask", !editMaskVisible && "d-none")}
+ onClick={() => setEditMaskVisible(false)}
+ >
+ <button className="text-button primary" onClick={on[kModify]}>
+ {t("admin:user.modify")}
+ </button>
+ <button className="text-button primary" onClick={on[kModifyPermission]}>
+ {t("admin:user.modifyPermissions")}
+ </button>
+ <button className="text-button danger" onClick={on[kDelete]}>
+ {t("admin:user.delete")}
+ </button>
+ </div>
+ </ListGroup.Item>
+ );
+};
+
+interface UserAdminProps {
+ user: AuthUser;
+}
+
+const UserAdmin: React.FC<UserAdminProps> = () => {
+ const { t } = useTranslation();
+
+ type DialogInfo =
+ | null
+ | {
+ type: "create";
+ }
+ | {
+ type: TModify;
+ user: HttpUser;
+ }
+ | {
+ type: TModifyPermission;
+ username: string;
+ permissions: UserPermission[];
+ }
+ | { type: TDelete; username: string };
+
+ const [users, setUsers] = useState<HttpUser[] | null>(null);
+ const [dialog, setDialog] = useState<DialogInfo>(null);
+ const [usersVersion, setUsersVersion] = useState<number>(0);
+ const updateUsers = (): void => {
+ setUsersVersion(usersVersion + 1);
+ };
+
+ useEffect(() => {
+ let subscribe = true;
+ void getHttpUserClient()
+ .list()
+ .then((us) => {
+ if (subscribe) {
+ setUsers(us);
+ }
+ });
+ return () => {
+ subscribe = false;
+ };
+ }, [usersVersion]);
+
+ let dialogNode: React.ReactNode;
+ if (dialog) {
+ switch (dialog.type) {
+ case "create":
+ dialogNode = (
+ <CreateUserDialog
+ open
+ close={() => setDialog(null)}
+ data={undefined}
+ onSuccess={updateUsers}
+ />
+ );
+ break;
+ case kDelete:
+ dialogNode = (
+ <UserDeleteDialog
+ open
+ close={() => setDialog(null)}
+ data={{ username: dialog.username }}
+ onSuccess={updateUsers}
+ />
+ );
+ break;
+ case kModify:
+ dialogNode = (
+ <UserModifyDialog
+ open
+ close={() => setDialog(null)}
+ data={{ oldUser: dialog.user }}
+ onSuccess={updateUsers}
+ />
+ );
+ break;
+ case kModifyPermission:
+ dialogNode = (
+ <UserPermissionModifyDialog
+ open
+ close={() => setDialog(null)}
+ data={{
+ username: dialog.username,
+ permissions: dialog.permissions,
+ }}
+ onSuccess={updateUsers}
+ />
+ );
+ break;
+ }
+ }
+
+ if (users) {
+ const userComponents = users.map((user) => {
+ return (
+ <UserItem
+ key={user.username}
+ user={user}
+ on={{
+ modify: () => {
+ setDialog({
+ type: "modify",
+ user,
+ });
+ },
+ permission: () => {
+ setDialog({
+ type: kModifyPermission,
+ username: user.username,
+ permissions: user.permissions,
+ });
+ },
+ delete: () => {
+ setDialog({
+ type: "delete",
+ username: user.username,
+ });
+ },
+ }}
+ />
+ );
+ });
+
+ return (
+ <>
+ <Row className="justify-content-end my-2">
+ <Col xs="auto">
+ <Button
+ variant="outline-success"
+ onClick={() =>
+ setDialog({
+ type: "create",
+ })
+ }
+ >
+ {t("admin:create")}
+ </Button>
+ </Col>
+ </Row>
+ {userComponents}
+ {dialogNode}
+ </>
+ );
+ } else {
+ return <Spinner animation="border" />;
+ }
+};
+
+export default UserAdmin;
diff --git a/FrontEnd/src/views/admin/admin.sass b/FrontEnd/src/views/admin/admin.sass
new file mode 100644
index 00000000..1ce010f8
--- /dev/null
+++ b/FrontEnd/src/views/admin/admin.sass
@@ -0,0 +1,22 @@
+.admin-user-item
+ position: relative
+
+ .edit-mask
+ position: absolute
+ top: 0
+ left: 0
+ bottom: 0
+ right: 0
+
+ background: #ffffffc5
+ position: absolute
+
+ display: flex
+ justify-content: center
+ align-items: center
+
+ @include media-breakpoint-down(xs)
+ flex-direction: column
+
+ button
+ margin: 0.5em 2em
diff --git a/FrontEnd/src/views/center/CenterBoards.tsx b/FrontEnd/src/views/center/CenterBoards.tsx
new file mode 100644
index 00000000..431d1e9a
--- /dev/null
+++ b/FrontEnd/src/views/center/CenterBoards.tsx
@@ -0,0 +1,107 @@
+import React from "react";
+import { Row, Col } from "react-bootstrap";
+import { useTranslation } from "react-i18next";
+
+import { pushAlert } from "@/services/alert";
+import { useUserLoggedIn } from "@/services/user";
+
+import { getHttpTimelineClient } from "http/timeline";
+import { getHttpBookmarkClient } from "http/bookmark";
+import { getHttpHighlightClient } from "http/highlight";
+
+import TimelineBoard from "./TimelineBoard";
+
+const CenterBoards: React.FC = () => {
+ const { t } = useTranslation();
+
+ const user = useUserLoggedIn();
+
+ return (
+ <>
+ <Row className="justify-content-center">
+ <Col xs="12" md="6">
+ <Row>
+ <Col xs="12" className="my-2">
+ <TimelineBoard
+ title={t("home.bookmarkTimeline")}
+ load={() => getHttpBookmarkClient().list()}
+ editHandler={{
+ onDelete: (timeline) => {
+ return getHttpBookmarkClient()
+ .delete(timeline)
+ .catch((e) => {
+ pushAlert({
+ message: "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
+ )
+ .catch((e) => {
+ pushAlert({
+ message: "home.message.moveBookmarkFail",
+ type: "danger",
+ });
+ throw e;
+ });
+ },
+ }}
+ />
+ </Col>
+ <Col xs="12" className="my-2">
+ <TimelineBoard
+ title={t("home.highlightTimeline")}
+ load={() => getHttpHighlightClient().list()}
+ editHandler={
+ user.hasHighlightTimelineAdministrationPermission
+ ? {
+ onDelete: (timeline) => {
+ return getHttpHighlightClient()
+ .delete(timeline)
+ .catch((e) => {
+ pushAlert({
+ message: "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
+ )
+ .catch((e) => {
+ pushAlert({
+ message: "home.message.moveHighlightFail",
+ type: "danger",
+ });
+ throw e;
+ });
+ },
+ }
+ : undefined
+ }
+ />
+ </Col>
+ </Row>
+ </Col>
+ <Col xs="12" md="6" className="my-2">
+ <TimelineBoard
+ title={t("home.relatedTimeline")}
+ load={() =>
+ getHttpTimelineClient().listTimeline({ relate: user.username })
+ }
+ />
+ </Col>
+ </Row>
+ </>
+ );
+};
+
+export default CenterBoards;
diff --git a/FrontEnd/src/views/center/TimelineBoard.tsx b/FrontEnd/src/views/center/TimelineBoard.tsx
new file mode 100644
index 00000000..bb80266b
--- /dev/null
+++ b/FrontEnd/src/views/center/TimelineBoard.tsx
@@ -0,0 +1,370 @@
+import React from "react";
+import classnames from "classnames";
+import { Link } from "react-router-dom";
+import { useTranslation } from "react-i18next";
+import { Spinner } from "react-bootstrap";
+
+import { HttpTimelineInfo } from "http/timeline";
+
+import TimelineLogo from "../common/TimelineLogo";
+import UserTimelineLogo from "../common/UserTimelineLogo";
+import LoadFailReload from "../common/LoadFailReload";
+
+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="ms-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 = {
+ transform:
+ arbitraryOffset != null
+ ? `translate(0,${arbitraryOffset}px)`
+ : offset != null
+ ? `translate(0,${offset * 100}%)`
+ : undefined,
+ transition: offset != null ? "transform 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: HttpTimelineInfo[];
+ 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: HttpTimelineInfo[] | "offline" | "loading";
+ onReload: () => void;
+ className?: string;
+ editHandler?: {
+ onMove: (timeline: string, index: number, offset: number) => void;
+ onDelete: (timeline: string) => void;
+ };
+}
+
+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={classnames("timeline-board", className)}>
+ <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 (
+ <div className="d-flex flex-grow-1 justify-content-center align-items-center">
+ <Spinner variant="primary" animation="border" />
+ </div>
+ );
+ } else if (timelines === "offline") {
+ return (
+ <div className="d-flex flex-grow-1 justify-content-center align-items-center">
+ <LoadFailReload onReload={props.onReload} />
+ </div>
+ );
+ } else {
+ 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<HttpTimelineInfo[]>;
+ 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<
+ HttpTimelineInfo[] | "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/views/center/TimelineCreateDialog.tsx b/FrontEnd/src/views/center/TimelineCreateDialog.tsx
new file mode 100644
index 00000000..a2437ae5
--- /dev/null
+++ b/FrontEnd/src/views/center/TimelineCreateDialog.tsx
@@ -0,0 +1,53 @@
+import React from "react";
+import { useHistory } from "react-router";
+
+import { validateTimelineName } from "@/services/timeline";
+import OperationDialog from "../common/OperationDialog";
+import { getHttpTimelineClient, HttpTimelineInfo } from "http/timeline";
+
+interface TimelineCreateDialogProps {
+ open: boolean;
+ close: () => void;
+}
+
+const TimelineCreateDialog: React.FC<TimelineCreateDialogProps> = (props) => {
+ const history = useHistory();
+
+ return (
+ <OperationDialog
+ open={props.open}
+ close={props.close}
+ themeColor="success"
+ title="home.createDialog.title"
+ inputScheme={
+ [
+ {
+ type: "text",
+ label: "home.createDialog.name",
+ helperText: "home.createDialog.nameFormat",
+ },
+ ] as const
+ }
+ inputValidator={([name]) => {
+ if (name.length === 0) {
+ return { 0: "home.createDialog.noEmpty" };
+ } else if (name.length > 26) {
+ return { 0: "home.createDialog.tooLong" };
+ } else if (!validateTimelineName(name)) {
+ return { 0: "home.createDialog.badFormat" };
+ } else {
+ return null;
+ }
+ }}
+ onProcess={([name]): Promise<HttpTimelineInfo> =>
+ getHttpTimelineClient().postTimeline({ name })
+ }
+ onSuccessAndClose={(timeline: HttpTimelineInfo) => {
+ history.push(`timelines/${timeline.name}`);
+ }}
+ failurePrompt={(e) => `${e as string}`}
+ />
+ );
+};
+
+export default TimelineCreateDialog;
diff --git a/FrontEnd/src/views/center/center.sass b/FrontEnd/src/views/center/center.sass
new file mode 100644
index 00000000..c0dfb9c0
--- /dev/null
+++ b/FrontEnd/src/views/center/center.sass
@@ -0,0 +1,36 @@
+.timeline-board
+ @extend .cru-card
+ @extend .d-flex
+ @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
+ height: 48px
+ transition: background 0.3s
+ display: flex
+ align-items: center
+ .icon
+ height: 1.3em
+ color: black
+ @extend .me-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/views/center/index.tsx b/FrontEnd/src/views/center/index.tsx
new file mode 100644
index 00000000..0a2abb2c
--- /dev/null
+++ b/FrontEnd/src/views/center/index.tsx
@@ -0,0 +1,64 @@
+import React from "react";
+import { useHistory } from "react-router";
+import { useTranslation } from "react-i18next";
+import { Row, Container, Button, Col } from "react-bootstrap";
+
+import { useUserLoggedIn } from "@/services/user";
+
+import SearchInput from "../common/SearchInput";
+import CenterBoards from "./CenterBoards";
+import TimelineCreateDialog from "./TimelineCreateDialog";
+
+const HomePage: React.FC = () => {
+ const history = useHistory();
+
+ const { t } = useTranslation();
+
+ const user = useUserLoggedIn();
+
+ const [navText, setNavText] = React.useState<string>("");
+
+ const [dialog, setDialog] = React.useState<"create" | null>(null);
+
+ return (
+ <>
+ <Container>
+ <Row className="my-3 justify-content-center">
+ <Col xs={12} sm={8} lg={6}>
+ <SearchInput
+ className="justify-content-center"
+ value={navText}
+ onChange={setNavText}
+ onButtonClick={() => {
+ history.push(`search?q=${navText}`);
+ }}
+ additionalButton={
+ user != null && (
+ <Button
+ variant="outline-success"
+ onClick={() => {
+ setDialog("create");
+ }}
+ >
+ {t("home.createButton")}
+ </Button>
+ )
+ }
+ />
+ </Col>
+ </Row>
+ <CenterBoards />
+ </Container>
+ {dialog === "create" && (
+ <TimelineCreateDialog
+ open
+ close={() => {
+ setDialog(null);
+ }}
+ />
+ )}
+ </>
+ );
+};
+
+export default HomePage;
diff --git a/FrontEnd/src/views/common/AppBar.tsx b/FrontEnd/src/views/common/AppBar.tsx
new file mode 100644
index 00000000..91dfbee9
--- /dev/null
+++ b/FrontEnd/src/views/common/AppBar.tsx
@@ -0,0 +1,80 @@
+import React from "react";
+import { useTranslation } from "react-i18next";
+import { Link, NavLink } from "react-router-dom";
+import classnames from "classnames";
+import { useMediaQuery } from "react-responsive";
+
+import { useUser } from "@/services/user";
+
+import TimelineLogo from "./TimelineLogo";
+import UserAvatar from "./user/UserAvatar";
+
+const AppBar: React.FC = (_) => {
+ const { t } = useTranslation();
+
+ const user = useUser();
+ const hasAdministrationPermission = user && user.hasAdministrationPermission;
+
+ const isSmallScreen = useMediaQuery({ maxWidth: 576 });
+
+ const [expand, setExpand] = React.useState<boolean>(false);
+ const collapse = (): void => setExpand(false);
+ const toggleExpand = (): void => setExpand(!expand);
+
+ const createLink = (
+ link: string,
+ label: React.ReactNode,
+ className?: string
+ ): React.ReactNode => (
+ <NavLink
+ to={link}
+ activeClassName="active"
+ onClick={collapse}
+ className={className}
+ >
+ {label}
+ </NavLink>
+ );
+
+ return (
+ <nav className={classnames("app-bar", isSmallScreen && "small-screen")}>
+ <Link to="/" className="app-bar-brand active">
+ <TimelineLogo className="app-bar-brand-icon" />
+ Timeline
+ </Link>
+
+ {isSmallScreen && (
+ <i className="bi-list app-bar-toggler" onClick={toggleExpand} />
+ )}
+
+ <div
+ className={classnames(
+ "app-bar-main-area",
+ !expand && "app-bar-collapse"
+ )}
+ >
+ <div className="app-bar-link-area">
+ {createLink("/settings", t("nav.settings"))}
+ {createLink("/about", t("nav.about"))}
+ {hasAdministrationPermission &&
+ createLink("/admin", t("nav.administration"))}
+ </div>
+
+ <div className="app-bar-user-area">
+ {user != null
+ ? createLink(
+ "/",
+ <UserAvatar
+ username={user.username}
+ className="avatar small rounded-circle bg-white cursor-pointer ml-auto"
+ />,
+ "app-bar-avatar"
+ )
+ : createLink("/login", t("nav.login"))}
+ </div>
+ </div>
+ </nav>
+ );
+};
+
+export default AppBar;
diff --git a/FrontEnd/src/views/common/BlobImage.tsx b/FrontEnd/src/views/common/BlobImage.tsx
new file mode 100644
index 00000000..0dd25c52
--- /dev/null
+++ b/FrontEnd/src/views/common/BlobImage.tsx
@@ -0,0 +1,27 @@
+import React from "react";
+
+const BlobImage: React.FC<
+ Omit<React.ImgHTMLAttributes<HTMLImageElement>, "src"> & {
+ blob?: Blob | unknown;
+ }
+> = (props) => {
+ const { blob, ...otherProps } = props;
+
+ const [url, setUrl] = React.useState<string | undefined>(undefined);
+
+ React.useEffect(() => {
+ if (blob instanceof Blob) {
+ const url = URL.createObjectURL(blob);
+ setUrl(url);
+ return () => {
+ URL.revokeObjectURL(url);
+ };
+ } else {
+ setUrl(undefined);
+ }
+ }, [blob]);
+
+ return <img {...otherProps} src={url} />;
+};
+
+export default BlobImage;
diff --git a/FrontEnd/src/views/common/ConfirmDialog.tsx b/FrontEnd/src/views/common/ConfirmDialog.tsx
new file mode 100644
index 00000000..72940c51
--- /dev/null
+++ b/FrontEnd/src/views/common/ConfirmDialog.tsx
@@ -0,0 +1,40 @@
+import { convertI18nText, I18nText } from "@/common";
+import React from "react";
+import { Modal, Button } from "react-bootstrap";
+import { useTranslation } from "react-i18next";
+
+const ConfirmDialog: React.FC<{
+ onClose: () => void;
+ onConfirm: () => void;
+ title: I18nText;
+ body: I18nText;
+}> = ({ onClose, onConfirm, title, body }) => {
+ const { t } = useTranslation();
+
+ return (
+ <Modal onHide={onClose} show centered>
+ <Modal.Header>
+ <Modal.Title className="text-danger">
+ {convertI18nText(title, t)}
+ </Modal.Title>
+ </Modal.Header>
+ <Modal.Body>{convertI18nText(body, t)}</Modal.Body>
+ <Modal.Footer>
+ <Button variant="secondary" onClick={onClose}>
+ {t("operationDialog.cancel")}
+ </Button>
+ <Button
+ variant="danger"
+ onClick={() => {
+ onConfirm();
+ onClose();
+ }}
+ >
+ {t("operationDialog.confirm")}
+ </Button>
+ </Modal.Footer>
+ </Modal>
+ );
+};
+
+export default ConfirmDialog;
diff --git a/FrontEnd/src/views/common/FlatButton.tsx b/FrontEnd/src/views/common/FlatButton.tsx
new file mode 100644
index 00000000..b1f7a051
--- /dev/null
+++ b/FrontEnd/src/views/common/FlatButton.tsx
@@ -0,0 +1,36 @@
+import React from "react";
+import classnames from "classnames";
+
+import { BootstrapThemeColor } from "@/common";
+
+export interface FlatButtonProps {
+ variant?: BootstrapThemeColor | string;
+ disabled?: boolean;
+ className?: string;
+ style?: React.CSSProperties;
+ onClick?: () => void;
+}
+
+const FlatButton: React.FC<FlatButtonProps> = (props) => {
+ const { disabled, className, style } = props;
+ const variant = props.variant ?? "primary";
+
+ const onClick = disabled ? undefined : props.onClick;
+
+ return (
+ <div
+ className={classnames(
+ "flat-button",
+ variant,
+ disabled ? "disabled" : null,
+ className
+ )}
+ style={style}
+ onClick={onClick}
+ >
+ {props.children}
+ </div>
+ );
+};
+
+export default FlatButton;
diff --git a/FrontEnd/src/views/common/FullPage.tsx b/FrontEnd/src/views/common/FullPage.tsx
new file mode 100644
index 00000000..1b59045a
--- /dev/null
+++ b/FrontEnd/src/views/common/FullPage.tsx
@@ -0,0 +1,39 @@
+import React from "react";
+import classnames from "classnames";
+
+export interface FullPageProps {
+ show: boolean;
+ onBack: () => void;
+ contentContainerClassName?: string;
+}
+
+const FullPage: React.FC<FullPageProps> = ({
+ show,
+ onBack,
+ children,
+ contentContainerClassName,
+}) => {
+ return (
+ <div
+ className="cru-full-page"
+ style={{ display: show ? undefined : "none" }}
+ >
+ <div className="cru-full-page-top-bar">
+ <i
+ className="icon-button bi-arrow-left text-white ms-3"
+ onClick={onBack}
+ />
+ </div>
+ <div
+ className={classnames(
+ "cru-full-page-content-container",
+ contentContainerClassName
+ )}
+ >
+ {children}
+ </div>
+ </div>
+ );
+};
+
+export default FullPage;
diff --git a/FrontEnd/src/views/common/ImageCropper.tsx b/FrontEnd/src/views/common/ImageCropper.tsx
new file mode 100644
index 00000000..2ef5b7ed
--- /dev/null
+++ b/FrontEnd/src/views/common/ImageCropper.tsx
@@ -0,0 +1,306 @@
+import React from "react";
+import classnames from "classnames";
+
+import { UiLogicError } from "@/common";
+
+export interface Clip {
+ left: number;
+ top: number;
+ width: number;
+}
+
+interface NormailizedClip extends Clip {
+ height: number;
+}
+
+interface ImageInfo {
+ width: number;
+ height: number;
+ landscape: boolean;
+ ratio: number;
+ maxClipWidth: number;
+ maxClipHeight: number;
+}
+
+interface ImageCropperSavedState {
+ clip: NormailizedClip;
+ x: number;
+ y: number;
+ pointerId: number;
+}
+
+export interface ImageCropperProps {
+ clip: Clip | null;
+ imageUrl: string;
+ onChange: (clip: Clip) => void;
+ imageElementCallback?: (element: HTMLImageElement | null) => void;
+ className?: string;
+}
+
+const ImageCropper = (props: ImageCropperProps): React.ReactElement => {
+ const { clip, imageUrl, onChange, imageElementCallback, className } = props;
+
+ const [oldState, setOldState] = React.useState<ImageCropperSavedState | null>(
+ null
+ );
+ const [imageInfo, setImageInfo] = React.useState<ImageInfo | null>(null);
+
+ const normalizeClip = (c: Clip | null | undefined): NormailizedClip => {
+ if (c == null) {
+ return { left: 0, top: 0, width: 0, height: 0 };
+ }
+
+ return {
+ left: c.left || 0,
+ top: c.top || 0,
+ width: c.width || 0,
+ height: imageInfo != null ? (c.width || 0) / imageInfo.ratio : 0,
+ };
+ };
+
+ const c = normalizeClip(clip);
+
+ const imgElementRef = React.useRef<HTMLImageElement | null>(null);
+
+ const onImageRef = React.useCallback(
+ (e: HTMLImageElement | null) => {
+ imgElementRef.current = e;
+ if (imageElementCallback != null && e == null) {
+ imageElementCallback(null);
+ }
+ },
+ [imageElementCallback]
+ );
+
+ const onImageLoad = React.useCallback(
+ (e: React.SyntheticEvent<HTMLImageElement>) => {
+ const img = e.currentTarget;
+ const landscape = img.naturalWidth >= img.naturalHeight;
+
+ const info = {
+ width: img.naturalWidth,
+ height: img.naturalHeight,
+ landscape,
+ ratio: img.naturalHeight / img.naturalWidth,
+ maxClipWidth: landscape ? img.naturalHeight / img.naturalWidth : 1,
+ maxClipHeight: landscape ? 1 : img.naturalWidth / img.naturalHeight,
+ };
+ setImageInfo(info);
+ onChange({ left: 0, top: 0, width: info.maxClipWidth });
+ if (imageElementCallback != null) {
+ imageElementCallback(img);
+ }
+ },
+ [onChange, imageElementCallback]
+ );
+
+ const onPointerDown = React.useCallback(
+ (e: React.PointerEvent) => {
+ if (oldState != null) return;
+ e.currentTarget.setPointerCapture(e.pointerId);
+ setOldState({
+ x: e.clientX,
+ y: e.clientY,
+ clip: c,
+ pointerId: e.pointerId,
+ });
+ },
+ [oldState, c]
+ );
+
+ const onPointerUp = React.useCallback(
+ (e: React.PointerEvent) => {
+ if (oldState == null || oldState.pointerId !== e.pointerId) return;
+ e.currentTarget.releasePointerCapture(e.pointerId);
+ setOldState(null);
+ },
+ [oldState]
+ );
+
+ const onPointerMove = React.useCallback(
+ (e: React.PointerEvent) => {
+ if (oldState == null) return;
+
+ const oldClip = oldState.clip;
+
+ const movement = { x: e.clientX - oldState.x, y: e.clientY - oldState.y };
+
+ const { current: imgElement } = imgElementRef;
+
+ if (imgElement == null) throw new UiLogicError("Image element is null.");
+
+ const moveRatio = {
+ x: movement.x / imgElement.width,
+ y: movement.y / imgElement.height,
+ };
+
+ const newRatio = {
+ x: oldClip.left + moveRatio.x,
+ y: oldClip.top + moveRatio.y,
+ };
+ if (newRatio.x < 0) {
+ newRatio.x = 0;
+ } else if (newRatio.x > 1 - oldClip.width) {
+ newRatio.x = 1 - oldClip.width;
+ }
+ if (newRatio.y < 0) {
+ newRatio.y = 0;
+ } else if (newRatio.y > 1 - oldClip.height) {
+ newRatio.y = 1 - oldClip.height;
+ }
+
+ onChange({ left: newRatio.x, top: newRatio.y, width: oldClip.width });
+ },
+ [oldState, onChange]
+ );
+
+ const onHandlerPointerMove = React.useCallback(
+ (e: React.PointerEvent) => {
+ if (oldState == null) return;
+
+ const oldClip = oldState.clip;
+
+ const movement = { x: e.clientX - oldState.x, y: e.clientY - oldState.y };
+
+ const ratio = imageInfo == null ? 1 : imageInfo.ratio;
+
+ const { current: imgElement } = imgElementRef;
+
+ if (imgElement == null) throw new UiLogicError("Image element is null.");
+
+ const moveRatio = {
+ x: movement.x / imgElement.width,
+ y: movement.x / imgElement.width / ratio,
+ };
+
+ const newRatio = {
+ x: oldClip.width + moveRatio.x,
+ y: oldClip.height + moveRatio.y,
+ };
+
+ const maxRatio = {
+ x: Math.min(1 - oldClip.left, newRatio.x),
+ y: Math.min(1 - oldClip.top, newRatio.y),
+ };
+
+ const maxWidthRatio = Math.min(maxRatio.x, maxRatio.y * ratio);
+
+ let newWidth;
+ if (newRatio.x < 0) {
+ newWidth = 0;
+ } else if (newRatio.x > maxWidthRatio) {
+ newWidth = maxWidthRatio;
+ } else {
+ newWidth = newRatio.x;
+ }
+
+ onChange({ left: oldClip.left, top: oldClip.top, width: newWidth });
+ },
+ [imageInfo, oldState, onChange]
+ );
+
+ const toPercentage = (n: number): string => `${n}%`;
+
+ // fuck!!! I just can't find a better way to implement this in pure css
+ const containerStyle: React.CSSProperties = (() => {
+ if (imageInfo == null) {
+ return { width: "100%", paddingTop: "100%", height: 0 };
+ } else {
+ if (imageInfo.ratio > 1) {
+ return {
+ width: toPercentage(100 / imageInfo.ratio),
+ paddingTop: "100%",
+ height: 0,
+ };
+ } else {
+ return {
+ width: "100%",
+ paddingTop: toPercentage(100 * imageInfo.ratio),
+ height: 0,
+ };
+ }
+ }
+ })();
+
+ return (
+ <div
+ className={classnames("image-cropper-container", className)}
+ style={containerStyle}
+ >
+ <img ref={onImageRef} src={imageUrl} onLoad={onImageLoad} alt="to crop" />
+ <div className="image-cropper-mask-container">
+ <div
+ className="image-cropper-mask"
+ touch-action="none"
+ style={{
+ left: toPercentage(c.left * 100),
+ top: toPercentage(c.top * 100),
+ width: toPercentage(c.width * 100),
+ height: toPercentage(c.height * 100),
+ }}
+ onPointerMove={onPointerMove}
+ onPointerDown={onPointerDown}
+ onPointerUp={onPointerUp}
+ />
+ </div>
+ <div
+ className="image-cropper-handler"
+ touch-action="none"
+ style={{
+ left: `calc(${(c.left + c.width) * 100}% - 15px)`,
+ top: `calc(${(c.top + c.height) * 100}% - 15px)`,
+ }}
+ onPointerMove={onHandlerPointerMove}
+ onPointerDown={onPointerDown}
+ onPointerUp={onPointerUp}
+ />
+ </div>
+ );
+};
+
+export default ImageCropper;
+
+export function applyClipToImage(
+ image: HTMLImageElement,
+ clip: Clip,
+ mimeType: string
+): Promise<Blob> {
+ return new Promise((resolve, reject) => {
+ const naturalSize = {
+ width: image.naturalWidth,
+ height: image.naturalHeight,
+ };
+ const clipArea = {
+ x: naturalSize.width * clip.left,
+ y: naturalSize.height * clip.top,
+ length: naturalSize.width * clip.width,
+ };
+
+ const canvas = document.createElement("canvas");
+ canvas.width = clipArea.length;
+ canvas.height = clipArea.length;
+ const context = canvas.getContext("2d");
+
+ if (context == null) throw new Error("Failed to create context.");
+
+ context.drawImage(
+ image,
+ clipArea.x,
+ clipArea.y,
+ clipArea.length,
+ clipArea.length,
+ 0,
+ 0,
+ clipArea.length,
+ clipArea.length
+ );
+
+ canvas.toBlob((blob) => {
+ if (blob == null) {
+ reject(new Error("canvas.toBlob returns null"));
+ } else {
+ resolve(blob);
+ }
+ }, mimeType);
+ });
+}
diff --git a/FrontEnd/src/views/common/LoadFailReload.tsx b/FrontEnd/src/views/common/LoadFailReload.tsx
new file mode 100644
index 00000000..a80e7b76
--- /dev/null
+++ b/FrontEnd/src/views/common/LoadFailReload.tsx
@@ -0,0 +1,37 @@
+import React from "react";
+import { Trans } from "react-i18next";
+
+export interface LoadFailReloadProps {
+ className?: string;
+ style?: React.CSSProperties;
+ onReload: () => void;
+}
+
+const LoadFailReload: React.FC<LoadFailReloadProps> = ({
+ onReload,
+ className,
+ style,
+}) => {
+ return (
+ <Trans
+ i18nKey="loadFailReload"
+ parent="div"
+ className={className}
+ style={style}
+ >
+ 0
+ <a
+ href="#"
+ onClick={(e) => {
+ onReload();
+ e.preventDefault();
+ }}
+ >
+ 1
+ </a>
+ 2
+ </Trans>
+ );
+};
+
+export default LoadFailReload;
diff --git a/FrontEnd/src/views/common/LoadingButton.tsx b/FrontEnd/src/views/common/LoadingButton.tsx
new file mode 100644
index 00000000..cd9f1adc
--- /dev/null
+++ b/FrontEnd/src/views/common/LoadingButton.tsx
@@ -0,0 +1,29 @@
+import React from "react";
+import { Button, ButtonProps, Spinner } from "react-bootstrap";
+
+const LoadingButton: React.FC<{ loading?: boolean } & ButtonProps> = ({
+ loading,
+ variant,
+ disabled,
+ ...otherProps
+}) => {
+ return (
+ <Button
+ variant={variant != null ? `outline-${variant}` : "outline-primary"}
+ disabled={disabled || loading}
+ {...otherProps}
+ >
+ {otherProps.children}
+ {loading ? (
+ <Spinner
+ className="ms-1"
+ variant={variant}
+ animation="grow"
+ size="sm"
+ />
+ ) : null}
+ </Button>
+ );
+};
+
+export default LoadingButton;
diff --git a/FrontEnd/src/views/common/LoadingPage.tsx b/FrontEnd/src/views/common/LoadingPage.tsx
new file mode 100644
index 00000000..590fafa0
--- /dev/null
+++ b/FrontEnd/src/views/common/LoadingPage.tsx
@@ -0,0 +1,12 @@
+import React from "react";
+import { Spinner } from "react-bootstrap";
+
+const LoadingPage: React.FC = () => {
+ return (
+ <div className="position-fixed w-100 h-100 d-flex justify-content-center align-items-center">
+ <Spinner variant="primary" animation="border" />
+ </div>
+ );
+};
+
+export default LoadingPage;
diff --git a/FrontEnd/src/views/common/Menu.tsx b/FrontEnd/src/views/common/Menu.tsx
new file mode 100644
index 00000000..ae73a331
--- /dev/null
+++ b/FrontEnd/src/views/common/Menu.tsx
@@ -0,0 +1,92 @@
+import React from "react";
+import classnames from "classnames";
+import { OverlayTrigger, OverlayTriggerProps, Popover } from "react-bootstrap";
+import { useTranslation } from "react-i18next";
+
+import { BootstrapThemeColor, convertI18nText, I18nText } from "@/common";
+
+export type MenuItem =
+ | {
+ type: "divider";
+ }
+ | {
+ type: "button";
+ text: I18nText;
+ iconClassName?: string;
+ color?: BootstrapThemeColor;
+ onClick: () => void;
+ };
+
+export type MenuItems = MenuItem[];
+
+export interface MenuProps {
+ items: MenuItems;
+ className?: string;
+ onItemClicked?: () => void;
+}
+
+const Menu: React.FC<MenuProps> = ({ items, className, onItemClicked }) => {
+ const { t } = useTranslation();
+
+ return (
+ <div className={classnames("cru-menu", className)}>
+ {items.map((item, index) => {
+ if (item.type === "divider") {
+ return <div key={index} className="cru-menu-divider" />;
+ } else {
+ return (
+ <div
+ key={index}
+ className={classnames(
+ "cru-menu-item",
+ `color-${item.color ?? "primary"}`
+ )}
+ onClick={() => {
+ item.onClick();
+ onItemClicked?.();
+ }}
+ >
+ {item.iconClassName != null ? (
+ <i
+ className={classnames(
+ item.iconClassName,
+ "cru-menu-item-icon"
+ )}
+ />
+ ) : null}
+ {convertI18nText(item.text, t)}
+ </div>
+ );
+ }
+ })}
+ </div>
+ );
+};
+
+export default Menu;
+
+export interface PopupMenuProps {
+ items: MenuItems;
+ children: OverlayTriggerProps["children"];
+}
+
+export const PopupMenu: React.FC<PopupMenuProps> = ({ items, children }) => {
+ const [show, setShow] = React.useState<boolean>(false);
+ const toggle = (): void => setShow(!show);
+
+ return (
+ <OverlayTrigger
+ trigger="click"
+ rootClose
+ overlay={
+ <Popover id="menu-popover">
+ <Menu items={items} onItemClicked={() => setShow(false)} />
+ </Popover>
+ }
+ show={show}
+ onToggle={toggle}
+ >
+ {children}
+ </OverlayTrigger>
+ );
+};
diff --git a/FrontEnd/src/views/common/OperationDialog.tsx b/FrontEnd/src/views/common/OperationDialog.tsx
new file mode 100644
index 00000000..ac4c51b9
--- /dev/null
+++ b/FrontEnd/src/views/common/OperationDialog.tsx
@@ -0,0 +1,471 @@
+import React, { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Form, Button, Modal } from "react-bootstrap";
+import { TwitterPicker } from "react-color";
+import moment from "moment";
+
+import { convertI18nText, I18nText, UiLogicError } from "@/common";
+
+import LoadingButton from "./LoadingButton";
+
+interface DefaultErrorPromptProps {
+ error?: string;
+}
+
+const DefaultErrorPrompt: React.FC<DefaultErrorPromptProps> = (props) => {
+ const { t } = useTranslation();
+
+ let result = <p className="text-danger">{t("operationDialog.error")}</p>;
+
+ if (props.error != null) {
+ result = (
+ <>
+ {result}
+ <p className="text-danger">{props.error}</p>
+ </>
+ );
+ }
+
+ return result;
+};
+
+export interface OperationDialogTextInput {
+ type: "text";
+ label?: I18nText;
+ password?: boolean;
+ initValue?: string;
+ textFieldProps?: Omit<
+ React.InputHTMLAttributes<HTMLInputElement>,
+ "type" | "value" | "onChange" | "aria-relevant"
+ >;
+ helperText?: string;
+}
+
+export interface OperationDialogBoolInput {
+ type: "bool";
+ label: I18nText;
+ initValue?: boolean;
+}
+
+export interface OperationDialogSelectInputOption {
+ value: string;
+ label: I18nText;
+ icon?: React.ReactElement;
+}
+
+export interface OperationDialogSelectInput {
+ type: "select";
+ label: I18nText;
+ options: OperationDialogSelectInputOption[];
+ initValue?: string;
+}
+
+export interface OperationDialogColorInput {
+ type: "color";
+ label?: I18nText;
+ initValue?: string | null;
+ canBeNull?: boolean;
+}
+
+export interface OperationDialogDateTimeInput {
+ type: "datetime";
+ label?: I18nText;
+ initValue?: string;
+}
+
+export type OperationDialogInput =
+ | OperationDialogTextInput
+ | OperationDialogBoolInput
+ | OperationDialogSelectInput
+ | OperationDialogColorInput
+ | OperationDialogDateTimeInput;
+
+interface OperationInputTypeStringToValueTypeMap {
+ text: string;
+ bool: boolean;
+ select: string;
+ color: string | null;
+ datetime: string;
+}
+
+type MapOperationInputTypeStringToValueType<Type> =
+ Type extends keyof OperationInputTypeStringToValueTypeMap
+ ? OperationInputTypeStringToValueTypeMap[Type]
+ : never;
+
+type MapOperationInputInfoValueType<T> = T extends OperationDialogInput
+ ? MapOperationInputTypeStringToValueType<T["type"]>
+ : T;
+
+const initValueMapperMap: {
+ [T in OperationDialogInput as T["type"]]: (
+ item: T
+ ) => MapOperationInputInfoValueType<T>;
+} = {
+ bool: (item) => item.initValue ?? false,
+ color: (item) => item.initValue ?? null,
+ datetime: (item) => {
+ if (item.initValue != null) {
+ return moment(item.initValue).format("YYYY-MM-DDTHH:mm:ss");
+ } else {
+ return "";
+ }
+ },
+ select: (item) => item.initValue ?? item.options[0].value,
+ text: (item) => item.initValue ?? "",
+};
+
+type MapOperationInputInfoValueTypeList<
+ Tuple extends readonly OperationDialogInput[]
+> = {
+ [Index in keyof Tuple]: MapOperationInputInfoValueType<Tuple[Index]>;
+} & { length: Tuple["length"] };
+
+export type OperationInputError =
+ | {
+ [index: number]: I18nText | null | undefined;
+ }
+ | null
+ | undefined;
+
+const isNoError = (error: OperationInputError): boolean => {
+ if (error == null) return true;
+ for (const key in error) {
+ if (error[key] != null) return false;
+ }
+ return true;
+};
+
+export interface OperationDialogProps<
+ TData,
+ OperationInputInfoList extends readonly OperationDialogInput[]
+> {
+ open: boolean;
+ close: () => void;
+ title: I18nText | (() => React.ReactNode);
+ themeColor?: "danger" | "success" | string;
+ onProcess: (
+ inputs: MapOperationInputInfoValueTypeList<OperationInputInfoList>
+ ) => Promise<TData>;
+ inputScheme?: OperationInputInfoList;
+ inputValidator?: (
+ inputs: MapOperationInputInfoValueTypeList<OperationInputInfoList>
+ ) => OperationInputError;
+ inputPrompt?: I18nText | (() => React.ReactNode);
+ processPrompt?: () => React.ReactNode;
+ successPrompt?: (data: TData) => React.ReactNode;
+ failurePrompt?: (error: unknown) => React.ReactNode;
+ onSuccessAndClose?: (data: TData) => void;
+}
+
+const OperationDialog = <
+ TData,
+ OperationInputInfoList extends readonly OperationDialogInput[]
+>(
+ props: OperationDialogProps<TData, OperationInputInfoList>
+): React.ReactElement => {
+ const inputScheme = (props.inputScheme ??
+ []) as readonly OperationDialogInput[];
+
+ const { t } = useTranslation();
+
+ type Step =
+ | { type: "input" }
+ | { type: "process" }
+ | {
+ type: "success";
+ data: TData;
+ }
+ | {
+ type: "failure";
+ data: unknown;
+ };
+ const [step, setStep] = useState<Step>({ type: "input" });
+
+ type ValueType = boolean | string | null | undefined;
+
+ const [values, setValues] = useState<ValueType[]>(
+ inputScheme.map((item) => {
+ if (item.type in initValueMapperMap) {
+ return (
+ initValueMapperMap[item.type] as (
+ i: OperationDialogInput
+ ) => ValueType
+ )(item);
+ } else {
+ throw new UiLogicError("Unknown input scheme.");
+ }
+ })
+ );
+ const [dirtyList, setDirtyList] = useState<boolean[]>(() =>
+ inputScheme.map(() => false)
+ );
+ const [inputError, setInputError] = useState<OperationInputError>();
+
+ const close = (): void => {
+ if (step.type !== "process") {
+ props.close();
+ if (step.type === "success" && props.onSuccessAndClose) {
+ props.onSuccessAndClose(step.data);
+ }
+ } else {
+ console.log("Attempt to close modal when processing.");
+ }
+ };
+
+ const onConfirm = (): void => {
+ setStep({ type: "process" });
+ props
+ .onProcess(
+ values.map((v, index) => {
+ if (inputScheme[index].type === "datetime" && v !== "")
+ return new Date(v as string).toISOString();
+ else return v;
+ }) as unknown as MapOperationInputInfoValueTypeList<OperationInputInfoList>
+ )
+ .then(
+ (d) => {
+ setStep({
+ type: "success",
+ data: d,
+ });
+ },
+ (e: unknown) => {
+ setStep({
+ type: "failure",
+ data: e,
+ });
+ }
+ );
+ };
+
+ let body: React.ReactNode;
+ if (step.type === "input" || step.type === "process") {
+ const process = step.type === "process";
+
+ let inputPrompt =
+ typeof props.inputPrompt === "function"
+ ? props.inputPrompt()
+ : convertI18nText(props.inputPrompt, t);
+ inputPrompt = <h6>{inputPrompt}</h6>;
+
+ const validate = (values: ValueType[]): boolean => {
+ const { inputValidator } = props;
+ if (inputValidator != null) {
+ const result = inputValidator(
+ values as unknown as MapOperationInputInfoValueTypeList<OperationInputInfoList>
+ );
+ setInputError(result);
+ return isNoError(result);
+ }
+ return true;
+ };
+
+ const updateValue = (index: number, newValue: ValueType): void => {
+ const oldValues = values;
+ const newValues = oldValues.slice();
+ newValues[index] = newValue;
+ setValues(newValues);
+ if (dirtyList[index] === false) {
+ const newDirtyList = dirtyList.slice();
+ newDirtyList[index] = true;
+ setDirtyList(newDirtyList);
+ }
+ validate(newValues);
+ };
+
+ const canProcess = isNoError(inputError);
+
+ body = (
+ <>
+ <Modal.Body>
+ {inputPrompt}
+ {inputScheme.map((item, index) => {
+ const value = values[index];
+ const error: string | null =
+ dirtyList[index] && inputError != null
+ ? convertI18nText(inputError[index], t)
+ : null;
+
+ if (item.type === "text") {
+ return (
+ <Form.Group key={index}>
+ {item.label && (
+ <Form.Label>{convertI18nText(item.label, t)}</Form.Label>
+ )}
+ <Form.Control
+ type={item.password === true ? "password" : "text"}
+ value={value as string}
+ onChange={(e) => {
+ const v = e.target.value;
+ updateValue(index, v);
+ }}
+ isInvalid={error != null}
+ disabled={process}
+ />
+ {error != null && (
+ <Form.Control.Feedback type="invalid">
+ {error}
+ </Form.Control.Feedback>
+ )}
+ {item.helperText && (
+ <Form.Text>{t(item.helperText)}</Form.Text>
+ )}
+ </Form.Group>
+ );
+ } else if (item.type === "bool") {
+ return (
+ <Form.Group key={index}>
+ <Form.Check<"input">
+ type="checkbox"
+ checked={value as boolean}
+ onChange={(event) => {
+ updateValue(index, event.currentTarget.checked);
+ }}
+ label={convertI18nText(item.label, t)}
+ disabled={process}
+ />
+ </Form.Group>
+ );
+ } else if (item.type === "select") {
+ return (
+ <Form.Group key={index}>
+ <Form.Label>{convertI18nText(item.label, t)}</Form.Label>
+ <Form.Control
+ as="select"
+ value={value as string}
+ onChange={(event) => {
+ updateValue(index, event.target.value);
+ }}
+ disabled={process}
+ >
+ {item.options.map((option, i) => {
+ return (
+ <option value={option.value} key={i}>
+ {option.icon}
+ {convertI18nText(option.label, t)}
+ </option>
+ );
+ })}
+ </Form.Control>
+ </Form.Group>
+ );
+ } else if (item.type === "color") {
+ return (
+ <Form.Group key={index}>
+ {item.canBeNull ? (
+ <Form.Check<"input">
+ type="checkbox"
+ checked={value !== null}
+ onChange={(event) => {
+ if (event.currentTarget.checked) {
+ updateValue(index, "#007bff");
+ } else {
+ updateValue(index, null);
+ }
+ }}
+ label={convertI18nText(item.label, t)}
+ disabled={process}
+ />
+ ) : (
+ <Form.Label>{convertI18nText(item.label, t)}</Form.Label>
+ )}
+ {value !== null && (
+ <TwitterPicker
+ color={value as string}
+ onChange={(result) => updateValue(index, result.hex)}
+ />
+ )}
+ </Form.Group>
+ );
+ } else if (item.type === "datetime") {
+ return (
+ <Form.Group key={index}>
+ {item.label && (
+ <Form.Label>{convertI18nText(item.label, t)}</Form.Label>
+ )}
+ <Form.Control
+ type="datetime-local"
+ value={value as string}
+ onChange={(e) => {
+ const v = e.target.value;
+ updateValue(index, v);
+ }}
+ isInvalid={error != null}
+ disabled={process}
+ />
+ {error != null && (
+ <Form.Control.Feedback type="invalid">
+ {error}
+ </Form.Control.Feedback>
+ )}
+ </Form.Group>
+ );
+ }
+ })}
+ </Modal.Body>
+ <Modal.Footer>
+ <Button variant="outline-secondary" onClick={close}>
+ {t("operationDialog.cancel")}
+ </Button>
+ <LoadingButton
+ variant={props.themeColor}
+ loading={process}
+ disabled={!canProcess}
+ onClick={() => {
+ setDirtyList(inputScheme.map(() => true));
+ if (validate(values)) {
+ onConfirm();
+ }
+ }}
+ >
+ {t("operationDialog.confirm")}
+ </LoadingButton>
+ </Modal.Footer>
+ </>
+ );
+ } else {
+ let content: React.ReactNode;
+ const result = step;
+ if (result.type === "success") {
+ content =
+ props.successPrompt?.(result.data) ?? t("operationDialog.success");
+ if (typeof content === "string")
+ content = <p className="text-success">{content}</p>;
+ } else {
+ content = props.failurePrompt?.(result.data) ?? <DefaultErrorPrompt />;
+ if (typeof content === "string")
+ content = <DefaultErrorPrompt error={content} />;
+ }
+ body = (
+ <>
+ <Modal.Body>{content}</Modal.Body>
+ <Modal.Footer>
+ <Button variant="primary" onClick={close}>
+ {t("operationDialog.ok")}
+ </Button>
+ </Modal.Footer>
+ </>
+ );
+ }
+
+ const title =
+ typeof props.title === "function"
+ ? props.title()
+ : convertI18nText(props.title, t);
+
+ return (
+ <Modal show={props.open} onHide={close}>
+ <Modal.Header
+ className={
+ props.themeColor != null ? "text-" + props.themeColor : undefined
+ }
+ >
+ {title}
+ </Modal.Header>
+ {body}
+ </Modal>
+ );
+};
+
+export default OperationDialog;
diff --git a/FrontEnd/src/views/common/SearchInput.tsx b/FrontEnd/src/views/common/SearchInput.tsx
new file mode 100644
index 00000000..ccb6dad6
--- /dev/null
+++ b/FrontEnd/src/views/common/SearchInput.tsx
@@ -0,0 +1,78 @@
+import React, { useCallback } from "react";
+import classnames from "classnames";
+import { useTranslation } from "react-i18next";
+import { Spinner, Form, Button } from "react-bootstrap";
+
+export interface SearchInputProps {
+ value: string;
+ onChange: (value: string) => void;
+ onButtonClick: () => void;
+ className?: string;
+ loading?: boolean;
+ buttonText?: string;
+ placeholder?: string;
+ additionalButton?: React.ReactNode;
+ alwaysOneline?: boolean;
+}
+
+const SearchInput: React.FC<SearchInputProps> = (props) => {
+ const { onChange, onButtonClick, alwaysOneline } = props;
+
+ const { t } = useTranslation();
+
+ const onInputChange = useCallback(
+ (event: React.ChangeEvent<HTMLInputElement>): void => {
+ onChange(event.currentTarget.value);
+ },
+ [onChange]
+ );
+
+ const onInputKeyPress = useCallback(
+ (event: React.KeyboardEvent<HTMLInputElement>): void => {
+ if (event.key === "Enter") {
+ onButtonClick();
+ event.preventDefault();
+ }
+ },
+ [onButtonClick]
+ );
+
+ return (
+ <Form
+ className={classnames(
+ "cru-search-input",
+ alwaysOneline ? "flex-nowrap" : "flex-sm-nowrap",
+ props.className
+ )}
+ >
+ <Form.Control
+ className="me-sm-2 flex-grow-1"
+ value={props.value}
+ onChange={onInputChange}
+ onKeyPress={onInputKeyPress}
+ placeholder={props.placeholder}
+ />
+ {props.additionalButton ? (
+ <div className="mt-2 mt-sm-0 flex-shrink-0 order-sm-last ms-sm-2">
+ {props.additionalButton}
+ </div>
+ ) : null}
+ <div
+ className={classnames(
+ alwaysOneline ? "mt-0 ms-2" : "mt-2 mt-sm-0 ms-auto ms-sm-0",
+ "flex-shrink-0"
+ )}
+ >
+ {props.loading ? (
+ <Spinner variant="primary" animation="border" />
+ ) : (
+ <Button variant="outline-primary" onClick={props.onButtonClick}>
+ {props.buttonText ?? t("search")}
+ </Button>
+ )}
+ </div>
+ </Form>
+ );
+};
+
+export default SearchInput;
diff --git a/FrontEnd/src/views/common/Skeleton.tsx b/FrontEnd/src/views/common/Skeleton.tsx
new file mode 100644
index 00000000..14886c71
--- /dev/null
+++ b/FrontEnd/src/views/common/Skeleton.tsx
@@ -0,0 +1,30 @@
+import React from "react";
+import classnames from "classnames";
+import { range } from "lodash";
+
+export interface SkeletonProps {
+ lineNumber?: number;
+ className?: string;
+ style?: React.CSSProperties;
+}
+
+const Skeleton: React.FC<SkeletonProps> = (props) => {
+ const { lineNumber: lineNumberProps, className, style } = props;
+ const lineNumber = lineNumberProps ?? 3;
+
+ return (
+ <div className={classnames(className, "cru-skeleton")} style={style}>
+ {range(lineNumber).map((i) => (
+ <div
+ key={i}
+ className={classnames(
+ "cru-skeleton-line",
+ i === lineNumber - 1 && "last"
+ )}
+ />
+ ))}
+ </div>
+ );
+};
+
+export default Skeleton;
diff --git a/FrontEnd/src/views/common/TabPages.tsx b/FrontEnd/src/views/common/TabPages.tsx
new file mode 100644
index 00000000..2b1d91cb
--- /dev/null
+++ b/FrontEnd/src/views/common/TabPages.tsx
@@ -0,0 +1,74 @@
+import React from "react";
+import { Nav } from "react-bootstrap";
+import { useTranslation } from "react-i18next";
+
+import { convertI18nText, I18nText, UiLogicError } from "@/common";
+
+export interface TabPage {
+ id: string;
+ tabText: I18nText;
+ page: React.ReactNode;
+}
+
+export interface TabPagesProps {
+ pages: TabPage[];
+ actions?: React.ReactNode;
+ className?: string;
+ style?: React.CSSProperties;
+ navClassName?: string;
+ navStyle?: React.CSSProperties;
+ pageContainerClassName?: string;
+ pageContainerStyle?: React.CSSProperties;
+}
+
+const TabPages: React.FC<TabPagesProps> = ({
+ pages,
+ actions,
+ className,
+ style,
+ navClassName,
+ navStyle,
+ pageContainerClassName,
+ pageContainerStyle,
+}) => {
+ if (pages.length === 0) {
+ throw new UiLogicError("Page list can't be empty.");
+ }
+
+ const { t } = useTranslation();
+
+ const [tab, setTab] = React.useState<string>(pages[0].id);
+
+ const currentPage = pages.find((p) => p.id === tab);
+
+ if (currentPage == null) {
+ throw new UiLogicError("Current tab value is bad.");
+ }
+
+ return (
+ <div className={className} style={style}>
+ <Nav variant="tabs" className={navClassName} style={navStyle}>
+ {pages.map((page) => (
+ <Nav.Item key={page.id}>
+ <Nav.Link
+ active={tab === page.id}
+ onClick={() => {
+ setTab(page.id);
+ }}
+ >
+ {convertI18nText(page.tabText, t)}
+ </Nav.Link>
+ </Nav.Item>
+ ))}
+ {actions != null && (
+ <div className="ms-auto cru-tab-pages-action-area">{actions}</div>
+ )}
+ </Nav>
+ <div className={pageContainerClassName} style={pageContainerStyle}>
+ {currentPage.page}
+ </div>
+ </div>
+ );
+};
+
+export default TabPages;
diff --git a/FrontEnd/src/views/common/TimelineLogo.tsx b/FrontEnd/src/views/common/TimelineLogo.tsx
new file mode 100644
index 00000000..27d188fc
--- /dev/null
+++ b/FrontEnd/src/views/common/TimelineLogo.tsx
@@ -0,0 +1,26 @@
+import React, { SVGAttributes } from "react";
+
+export interface TimelineLogoProps extends SVGAttributes<SVGElement> {
+ color?: string;
+}
+
+const TimelineLogo: React.FC<TimelineLogoProps> = (props) => {
+ const { color, ...forwardProps } = props;
+ const coercedColor = color ?? "currentcolor";
+ return (
+ <svg
+ className={props.className}
+ viewBox="0 0 100 100"
+ fill="none"
+ strokeWidth="12"
+ stroke={coercedColor}
+ {...forwardProps}
+ >
+ <line x1="50" y1="0" x2="50" y2="25" />
+ <circle cx="50" cy="50" r="22" />
+ <line x1="50" y1="75" x2="50" y2="100" />
+ </svg>
+ );
+};
+
+export default TimelineLogo;
diff --git a/FrontEnd/src/views/common/ToggleIconButton.tsx b/FrontEnd/src/views/common/ToggleIconButton.tsx
new file mode 100644
index 00000000..c4d2d132
--- /dev/null
+++ b/FrontEnd/src/views/common/ToggleIconButton.tsx
@@ -0,0 +1,30 @@
+import React from "react";
+import classnames from "classnames";
+
+export interface ToggleIconButtonProps
+ extends React.HTMLAttributes<HTMLElement> {
+ state: boolean;
+ trueIconClassName: string;
+ falseIconClassName: string;
+}
+
+const ToggleIconButton: React.FC<ToggleIconButtonProps> = ({
+ state,
+ className,
+ trueIconClassName,
+ falseIconClassName,
+ ...otherProps
+}) => {
+ return (
+ <i
+ className={classnames(
+ state ? trueIconClassName : falseIconClassName,
+ "icon-button",
+ className
+ )}
+ {...otherProps}
+ />
+ );
+};
+
+export default ToggleIconButton;
diff --git a/FrontEnd/src/views/common/UserTimelineLogo.tsx b/FrontEnd/src/views/common/UserTimelineLogo.tsx
new file mode 100644
index 00000000..19b9fee5
--- /dev/null
+++ b/FrontEnd/src/views/common/UserTimelineLogo.tsx
@@ -0,0 +1,26 @@
+import React, { SVGAttributes } from "react";
+
+export interface UserTimelineLogoProps extends SVGAttributes<SVGElement> {
+ color?: string;
+}
+
+const UserTimelineLogo: React.FC<UserTimelineLogoProps> = (props) => {
+ const { color, ...forwardProps } = props;
+ const coercedColor = color ?? "currentcolor";
+
+ return (
+ <svg viewBox="0 0 100 100" {...forwardProps}>
+ <g fill="none" stroke={coercedColor} strokeWidth="12">
+ <line x1="50" x2="50" y1="0" y2="25" />
+ <circle cx="50" cy="50" r="22" />
+ <line x1="50" x2="50" y1="75" y2="100" />
+ </g>
+ <g fill={coercedColor}>
+ <circle cx="85" cy="75" r="10" />
+ <path d="m70,100c0,0 15,-30 30,0.25" />
+ </g>
+ </svg>
+ );
+};
+
+export default UserTimelineLogo;
diff --git a/FrontEnd/src/views/common/alert/AlertHost.tsx b/FrontEnd/src/views/common/alert/AlertHost.tsx
new file mode 100644
index 00000000..949be7ed
--- /dev/null
+++ b/FrontEnd/src/views/common/alert/AlertHost.tsx
@@ -0,0 +1,106 @@
+import React from "react";
+import without from "lodash/without";
+import { useTranslation } from "react-i18next";
+import { Alert } from "react-bootstrap";
+
+import {
+ alertService,
+ AlertInfoEx,
+ kAlertHostId,
+ AlertInfo,
+} from "@/services/alert";
+import { convertI18nText } from "@/common";
+
+interface AutoCloseAlertProps {
+ alert: AlertInfo;
+ close: () => void;
+}
+
+export const AutoCloseAlert: React.FC<AutoCloseAlertProps> = (props) => {
+ const { alert, close } = props;
+ const { dismissTime } = alert;
+
+ const { t } = useTranslation();
+
+ const timerTag = React.useRef<number | null>(null);
+ const closeHandler = React.useRef<(() => void) | null>(null);
+
+ React.useEffect(() => {
+ closeHandler.current = close;
+ }, [close]);
+
+ React.useEffect(() => {
+ const tag =
+ dismissTime === "never"
+ ? null
+ : typeof dismissTime === "number"
+ ? window.setTimeout(() => closeHandler.current?.(), dismissTime)
+ : window.setTimeout(() => closeHandler.current?.(), 5000);
+ timerTag.current = tag;
+ return () => {
+ if (tag != null) {
+ window.clearTimeout(tag);
+ }
+ };
+ }, [dismissTime]);
+
+ const cancelTimer = (): void => {
+ const { current: tag } = timerTag;
+ if (tag != null) {
+ window.clearTimeout(tag);
+ }
+ };
+
+ return (
+ <Alert
+ className="m-3"
+ variant={alert.type ?? "primary"}
+ onClick={cancelTimer}
+ onClose={close}
+ dismissible
+ >
+ {(() => {
+ const { message } = alert;
+ if (typeof message === "function") {
+ const Message = message;
+ return <Message />;
+ } else return convertI18nText(message, t);
+ })()}
+ </Alert>
+ );
+};
+
+const AlertHost: React.FC = () => {
+ const [alerts, setAlerts] = React.useState<AlertInfoEx[]>([]);
+
+ // react guarantee that state setters are stable, so we don't need to add it to dependency list
+
+ React.useEffect(() => {
+ const consume = (alert: AlertInfoEx): void => {
+ setAlerts((old) => [...old, alert]);
+ };
+
+ alertService.registerConsumer(consume);
+ return () => {
+ alertService.unregisterConsumer(consume);
+ };
+ }, []);
+
+ return (
+ <div id={kAlertHostId} className="alert-container">
+ {alerts.map((alert) => {
+ return (
+ <AutoCloseAlert
+ key={alert.id}
+ alert={alert}
+ close={() => {
+ setAlerts((old) => without(old, alert));
+ }}
+ />
+ );
+ })}
+ </div>
+ );
+};
+
+export default AlertHost;
diff --git a/FrontEnd/src/views/common/alert/alert.sass b/FrontEnd/src/views/common/alert/alert.sass
new file mode 100644
index 00000000..c3560b87
--- /dev/null
+++ b/FrontEnd/src/views/common/alert/alert.sass
@@ -0,0 +1,15 @@
+.alert-container
+ position: fixed
+ z-index: $zindex-popover
+
+@include media-breakpoint-up(sm)
+ .alert-container
+ bottom: 0
+ right: 0
+
+@include media-breakpoint-down(sm)
+ .alert-container
+ bottom: 0
+ right: 0
+ left: 0
+ text-align: center
diff --git a/FrontEnd/src/views/common/common.sass b/FrontEnd/src/views/common/common.sass
new file mode 100644
index 00000000..cbf7292e
--- /dev/null
+++ b/FrontEnd/src/views/common/common.sass
@@ -0,0 +1,191 @@
+.image-cropper-container
+ position: relative
+ box-sizing: border-box
+ user-select: none
+
+.image-cropper-container img
+ position: absolute
+ left: 0
+ top: 0
+ width: 100%
+ height: 100%
+
+.image-cropper-mask-container
+ position: absolute
+ left: 0
+ top: 0
+ right: 0
+ bottom: 0
+ overflow: hidden
+
+.image-cropper-mask
+ position: absolute
+ box-shadow: 0 0 0 10000px rgba(255, 255, 255, 80%)
+ touch-action: none
+
+.image-cropper-handler
+ position: absolute
+ width: 26px
+ height: 26px
+ border: black solid 2px
+ border-radius: 50%
+ background: white
+ touch-action: none
+
+.app-bar
+ display: flex
+ align-items: center
+ height: 56px
+
+ position: fixed
+ z-index: 1030
+ top: 0
+ left: 0
+ right: 0
+
+ background-color: var(--tl-primary-color)
+
+ transition: background-color 1s
+
+ a
+ color: var(--tl-text-on-primary-inactive-color)
+ text-decoration: none
+ margin: 0 1em
+
+ &:hover
+ color: var(--tl-text-on-primary-color)
+
+ &.active
+ color: var(--tl-text-on-primary-color)
+
+.app-bar-brand
+ display: flex
+ align-items: center
+
+.app-bar-brand-icon
+ height: 2em
+
+.app-bar-main-area
+ display: flex
+ flex-grow: 1
+
+.app-bar-link-area
+ display: flex
+ align-items: center
+ flex-shrink: 0
+
+.app-bar-user-area
+ display: flex
+ align-items: center
+ flex-shrink: 0
+ margin-left: auto
+
+.small-screen
+ .app-bar-main-area
+ position: absolute
+ top: 56px
+ left: 0
+ right: 0
+
+ transform-origin: top
+ transition: transform 0.6s, background-color 1s
+
+ background-color: var(--tl-primary-color)
+
+ flex-direction: column
+
+ &.app-bar-collapse
+ transform: scale(1,0)
+
+ a
+ text-align: left
+ padding: 0.5em 0.5em
+
+ .app-bar-link-area
+ flex-direction: column
+ align-items: stretch
+
+ .app-bar-user-area
+ flex-direction: column
+ align-items: stretch
+ margin-left: unset
+
+ .app-bar-avatar
+ align-self: flex-end
+
+.app-bar-toggler
+ margin-left: auto
+ font-size: 2em
+ margin-right: 1em
+ color: var(--tl-text-on-primary-color)
+ cursor: pointer
+ user-select: none
+
+.cru-skeleton
+ padding: 0 1em
+
+.cru-skeleton-line
+ height: 1em
+ background-color: #e6e6e6
+ margin: 0.7em 0
+ border-radius: 0.2em
+
+ &.last
+ width: 50%
+
+.cru-full-page
+ position: fixed
+ z-index: 1031
+ left: 0
+ top: 0
+ right: 0
+ bottom: 0
+ background-color: white
+ padding-top: 56px
+
+.cru-full-page-top-bar
+ height: 56px
+
+ position: absolute
+ top: 0
+ left: 0
+ right: 0
+ z-index: 1
+
+ background-color: var(--tl-primary-color)
+
+ display: flex
+ align-items: center
+
+.cru-full-page-content-container
+ overflow: scroll
+
+.cru-menu
+ min-width: 200px
+
+.cru-menu-item
+ font-size: 1.2em
+ padding: 0.5em 1.5em
+ cursor: pointer
+
+ @each $color, $value in $theme-colors
+ &.color-#{$color}
+ color: $value
+
+ &:hover
+ color: white
+ background-color: $value
+
+.cru-menu-item-icon
+ margin-right: 1em
+
+.cru-menu-divider
+ border-top: 1px solid $gray-200
+
+.cru-tab-pages-action-area
+ display: flex
+ align-items: center
+
+.cru-search-input
+ display: flex
+ flex-wrap: wrap
diff --git a/FrontEnd/src/views/common/user/UserAvatar.tsx b/FrontEnd/src/views/common/user/UserAvatar.tsx
new file mode 100644
index 00000000..901697db
--- /dev/null
+++ b/FrontEnd/src/views/common/user/UserAvatar.tsx
@@ -0,0 +1,19 @@
+import React from "react";
+
+import { getHttpUserClient } from "http/user";
+
+export interface UserAvatarProps
+ extends React.ImgHTMLAttributes<HTMLImageElement> {
+ username: string;
+}
+
+const UserAvatar: React.FC<UserAvatarProps> = ({ username, ...otherProps }) => {
+ return (
+ <img
+ src={getHttpUserClient().generateAvatarUrl(username)}
+ {...otherProps}
+ />
+ );
+};
+
+export default UserAvatar;
diff --git a/FrontEnd/src/views/home/TimelineListView.tsx b/FrontEnd/src/views/home/TimelineListView.tsx
new file mode 100644
index 00000000..975875af
--- /dev/null
+++ b/FrontEnd/src/views/home/TimelineListView.tsx
@@ -0,0 +1,101 @@
+import React from "react";
+
+import { convertI18nText, I18nText } from "@/common";
+
+import { HttpTimelineInfo } from "http/timeline";
+import { useTranslation } from "react-i18next";
+import { Link } from "react-router-dom";
+
+interface TimelineListItemProps {
+ timeline: HttpTimelineInfo;
+}
+
+const TimelineListItem: React.FC<TimelineListItemProps> = ({ timeline }) => {
+ const url = React.useMemo(
+ () =>
+ timeline.name.startsWith("@")
+ ? `/users/${timeline.owner.username}`
+ : `/timelines/${timeline.name}`,
+ [timeline]
+ );
+
+ return (
+ <div className="home-timeline-list-item home-timeline-list-item-timeline">
+ <svg className="home-timeline-list-item-line" viewBox="0 0 120 100">
+ <path
+ d="M 80,50 m 0,-12 a 12 12 180 1 1 0,24 12 12 180 1 1 0,-24 z M 60,0 h 40 v 100 h -40 z"
+ fillRule="evenodd"
+ fill="#007bff"
+ />
+ </svg>
+ <div>
+ <div>{timeline.title}</div>
+ <div>
+ <small className="text-secondary">{timeline.description}</small>
+ </div>
+ </div>
+ <Link to={url}>
+ <i className="icon-button bi-arrow-right ms-3" />
+ </Link>
+ </div>
+ );
+};
+
+const TimelineListArrow: React.FC = () => {
+ return (
+ <div>
+ <div className="home-timeline-list-item">
+ <svg className="home-timeline-list-item-line" viewBox="0 0 120 60">
+ <path d="M 60,0 h 40 v 20 l -20,20 l -20,-20 z" fill="#007bff" />
+ </svg>
+ </div>
+ <div className="home-timeline-list-item">
+ <svg
+ className="home-timeline-list-item-line home-timeline-list-loading-head"
+ viewBox="0 0 120 40"
+ >
+ <path
+ d="M 60,10 l 20,20 l 20,-20"
+ fill="none"
+ stroke="#007bff"
+ strokeWidth="5"
+ />
+ </svg>
+ </div>
+ </div>
+ );
+};
+
+interface TimelineListViewProps {
+ headerText?: I18nText;
+ timelines?: HttpTimelineInfo[];
+}
+
+const TimelineListView: React.FC<TimelineListViewProps> = ({
+ headerText,
+ timelines,
+}) => {
+ const { t } = useTranslation();
+
+ return (
+ <div className="home-timeline-list">
+ <div className="home-timeline-list-item">
+ <svg className="home-timeline-list-item-line" viewBox="0 0 120 120">
+ <path
+ d="M 0,20 Q 80,20 80,80 l 0,40"
+ stroke="#007bff"
+ strokeWidth="40"
+ fill="none"
+ />
+ </svg>
+ <h3>{convertI18nText(headerText, t)}</h3>
+ </div>
+ {timelines != null
+ ? timelines.map((t) => <TimelineListItem key={t.name} timeline={t} />)
+ : null}
+ <TimelineListArrow />
+ </div>
+ );
+};
+
+export default TimelineListView;
diff --git a/FrontEnd/src/views/home/WebsiteIntroduction.tsx b/FrontEnd/src/views/home/WebsiteIntroduction.tsx
new file mode 100644
index 00000000..aea7b4b2
--- /dev/null
+++ b/FrontEnd/src/views/home/WebsiteIntroduction.tsx
@@ -0,0 +1,77 @@
+import React from "react";
+import { useTranslation } from "react-i18next";
+import { Link } from "react-router-dom";
+
+const WebsiteIntroduction: React.FC<{
+ className?: string;
+ style?: React.CSSProperties;
+}> = ({ className, style }) => {
+ const { i18n } = useTranslation();
+
+ if (i18n.language.startsWith("zh")) {
+ return (
+ <div className={className} style={style}>
+ <h2>
+ 欢迎来到<strong>时间线</strong>!🎉🎉🎉
+ </h2>
+ <p>
+ 本网站由无数个独立的时间线构成,每一个时间线都是一个消息列表,类似于一个聊天软件(比如QQ)。
+ </p>
+ <p>
+ 如果你拥有一个账号,<Link to="/login">登陆</Link>
+ 后你可以自由地在属于你的时间线中发送内容,支持markdown和上传图片哦!你可以创建一个新的时间线来开启一个新的话题。你也可以设置相关权限,只让一部分人能看到时间线的内容。
+ </p>
+ <p>
+ 如果你没有账号,那么你可以去浏览一下公开的时间线,比如下面这些站长设置的高光时间线。
+ </p>
+ <p>
+ 鉴于这个网站在我的小型服务器上部署,所以没有开放注册。如果你也想把这个服务部署到自己的服务器上,你可以在
+ <Link to="/about">关于</Link>页面找到一些信息。
+ </p>
+ <p>
+ <small className="text-secondary">
+ 这一段介绍是我的对象抱怨多次我的网站他根本看不明白之后加的,希望你能顺利看懂这个网站的逻辑!😅
+ </small>
+ </p>
+ </div>
+ );
+ } else {
+ return (
+ <div className={className} style={style}>
+ <h2>
+ Welcome to <strong>Timeline</strong>!🎉🎉🎉
+ </h2>
+ <p>
+ This website consists of many individual timelines. Each timeline is a
+ list of messages just like a chat app.
+ </p>
+ <p>
+ If you do have an account, you can <Link to="/login">login</Link> and
+ post messages, which supports Markdown and images, in your timelines.
+ You can also create a new timeline to open a new topic. You can set
+ the permission of a timeline to only allow specified people to see the
+ content of the timeline.
+ </p>
+ <p>
+ If you don&apos;t have an account, you can view some public timelines
+ like highlight timelines below set by website manager.
+ </p>
+ <p>
+ Since this website is hosted on my tiny server, so account registry is
+ not opened. If you want to host this service on your own server, you
+ can find some useful information on <Link to="/about">about</Link>{" "}
+ page.
+ </p>
+ <p>
+ <small className="text-secondary">
+ This introduction is added after my lover complained a lot of times
+ about the obscuration of my website. May you understand the logic of
+ it!😅
+ </small>
+ </p>
+ </div>
+ );
+ }
+};
+
+export default WebsiteIntroduction;
diff --git a/FrontEnd/src/views/home/home.sass b/FrontEnd/src/views/home/home.sass
new file mode 100644
index 00000000..b4cda586
--- /dev/null
+++ b/FrontEnd/src/views/home/home.sass
@@ -0,0 +1,29 @@
+.home-timeline-list-item
+ display: flex
+ align-items: center
+
+.home-timeline-list-item-timeline
+ transition: background 0.8s
+ animation: 0.8s home-timeline-list-item-timeline-enter
+ &:hover
+ background: $gray-200
+
+@keyframes home-timeline-list-item-timeline-enter
+ from
+ transform: translate(-100%,0)
+ opacity: 0
+
+.home-timeline-list-item-line
+ width: 80px
+ flex-shrink: 0
+
+@keyframes home-timeline-list-loading-head-animation
+ from
+ transform: translate(0,-30px)
+ opacity: 1
+
+ to
+ opacity: 0
+
+.home-timeline-list-loading-head
+ animation: 1s infinite home-timeline-list-loading-head-animation
diff --git a/FrontEnd/src/views/home/index.tsx b/FrontEnd/src/views/home/index.tsx
new file mode 100644
index 00000000..efc364d7
--- /dev/null
+++ b/FrontEnd/src/views/home/index.tsx
@@ -0,0 +1,74 @@
+import React from "react";
+import { useHistory } from "react-router";
+
+import { HttpTimelineInfo } from "http/timeline";
+import { getHttpHighlightClient } from "http/highlight";
+
+import SearchInput from "../common/SearchInput";
+import TimelineListView from "./TimelineListView";
+import WebsiteIntroduction from "./WebsiteIntroduction";
+
+const highlightTimelineMessageMap = {
+ loading: "home.loadingHighlightTimelines",
+ done: "home.loadedHighlightTimelines",
+ error: "home.errorHighlightTimelines",
+} as const;
+
+const HomeV2: React.FC = () => {
+ const history = useHistory();
+
+ const [navText, setNavText] = React.useState<string>("");
+
+ const [highlightTimelineState, setHighlightTimelineState] = React.useState<
+ "loading" | "done" | "error"
+ >("loading");
+ const [highlightTimelines, setHighlightTimelines] = React.useState<
+ HttpTimelineInfo[] | undefined
+ >();
+
+ React.useEffect(() => {
+ if (highlightTimelineState === "loading") {
+ let subscribe = true;
+ void getHttpHighlightClient()
+ .list()
+ .then(
+ (data) => {
+ if (subscribe) {
+ setHighlightTimelineState("done");
+ setHighlightTimelines(data);
+ }
+ },
+ () => {
+ if (subscribe) {
+ setHighlightTimelineState("error");
+ setHighlightTimelines(undefined);
+ }
+ }
+ );
+ return () => {
+ subscribe = false;
+ };
+ }
+ }, [highlightTimelineState]);
+
+ return (
+ <>
+ <SearchInput
+ className="mx-2 my-3 float-sm-end"
+ value={navText}
+ onChange={setNavText}
+ onButtonClick={() => {
+ history.push(`search?q=${navText}`);
+ }}
+ alwaysOneline
+ />
+ <WebsiteIntroduction className="m-2" />
+ <TimelineListView
+ headerText={highlightTimelineMessageMap[highlightTimelineState]}
+ timelines={highlightTimelines}
+ />
+ </>
+ );
+};
+
+export default HomeV2;
diff --git a/FrontEnd/src/views/login/index.tsx b/FrontEnd/src/views/login/index.tsx
new file mode 100644
index 00000000..6adcef39
--- /dev/null
+++ b/FrontEnd/src/views/login/index.tsx
@@ -0,0 +1,151 @@
+import React from "react";
+import { useHistory } from "react-router";
+import { useTranslation } from "react-i18next";
+import { Container, Form } from "react-bootstrap";
+
+import { useUser, userService } from "@/services/user";
+
+import AppBar from "../common/AppBar";
+import LoadingButton from "../common/LoadingButton";
+
+const LoginPage: React.FC = (_) => {
+ const { t } = useTranslation();
+ const history = useHistory();
+ const [username, setUsername] = React.useState<string>("");
+ const [usernameDirty, setUsernameDirty] = React.useState<boolean>(false);
+ const [password, setPassword] = React.useState<string>("");
+ const [passwordDirty, setPasswordDirty] = React.useState<boolean>(false);
+ const [rememberMe, setRememberMe] = React.useState<boolean>(true);
+ const [process, setProcess] = React.useState<boolean>(false);
+ const [error, setError] = React.useState<string | null>(null);
+
+ const user = useUser();
+
+ React.useEffect(() => {
+ if (user != null) {
+ const id = setTimeout(() => history.push("/"), 3000);
+ return () => {
+ clearTimeout(id);
+ };
+ }
+ }, [history, user]);
+
+ if (user != null) {
+ return (
+ <>
+ <AppBar />
+ <p>{t("login.alreadyLogin")}</p>
+ </>
+ );
+ }
+
+ const submit = (): void => {
+ if (username === "" || password === "") {
+ setUsernameDirty(true);
+ setPasswordDirty(true);
+ return;
+ }
+
+ setProcess(true);
+ userService
+ .login(
+ {
+ username: username,
+ password: password,
+ },
+ rememberMe
+ )
+ .then(
+ () => {
+ if (history.length === 0) {
+ history.push("/");
+ } else {
+ history.goBack();
+ }
+ },
+ (e: Error) => {
+ setProcess(false);
+ setError(e.message);
+ }
+ );
+ };
+
+ const onEnterPressInPassword: React.KeyboardEventHandler = (e) => {
+ if (e.key === "Enter") {
+ submit();
+ }
+ };
+
+ return (
+ <Container fluid className="login-container mt-2">
+ <h1 className="text-center">{t("welcome")}</h1>
+ <Form>
+ <Form.Group>
+ <Form.Label htmlFor="username">{t("user.username")}</Form.Label>
+ <Form.Control
+ id="username"
+ disabled={process}
+ onChange={(e) => {
+ setUsername(e.target.value);
+ setUsernameDirty(true);
+ }}
+ value={username}
+ isInvalid={usernameDirty && username === ""}
+ />
+ {usernameDirty && username === "" && (
+ <Form.Control.Feedback type="invalid">
+ {t("login.emptyUsername")}
+ </Form.Control.Feedback>
+ )}
+ </Form.Group>
+ <Form.Group>
+ <Form.Label htmlFor="password">{t("user.password")}</Form.Label>
+ <Form.Control
+ id="password"
+ type="password"
+ disabled={process}
+ onChange={(e) => {
+ setPassword(e.target.value);
+ setPasswordDirty(true);
+ }}
+ value={password}
+ onKeyDown={onEnterPressInPassword}
+ isInvalid={passwordDirty && password === ""}
+ />
+ {passwordDirty && password === "" && (
+ <Form.Control.Feedback type="invalid">
+ {t("login.emptyPassword")}
+ </Form.Control.Feedback>
+ )}
+ </Form.Group>
+ <Form.Group>
+ <Form.Check<"input">
+ id="remember-me"
+ type="checkbox"
+ checked={rememberMe}
+ onChange={(e) => {
+ setRememberMe(e.currentTarget.checked);
+ }}
+ label={t("user.rememberMe")}
+ />
+ </Form.Group>
+ {error ? <p className="text-danger">{t(error)}</p> : null}
+ <div className="text-end">
+ <LoadingButton
+ loading={process}
+ variant="primary"
+ onClick={(e) => {
+ submit();
+ e.preventDefault();
+ }}
+ disabled={username === "" || password === "" ? true : undefined}
+ >
+ {t("user.login")}
+ </LoadingButton>
+ </div>
+ </Form>
+ </Container>
+ );
+};
+
+export default LoginPage;
diff --git a/FrontEnd/src/views/login/login.sass b/FrontEnd/src/views/login/login.sass
new file mode 100644
index 00000000..0bf385f5
--- /dev/null
+++ b/FrontEnd/src/views/login/login.sass
@@ -0,0 +1,2 @@
+.login-container
+ max-width: 600px
diff --git a/FrontEnd/src/views/search/index.tsx b/FrontEnd/src/views/search/index.tsx
new file mode 100644
index 00000000..14a9709c
--- /dev/null
+++ b/FrontEnd/src/views/search/index.tsx
@@ -0,0 +1,128 @@
+import React from "react";
+import { useTranslation } from "react-i18next";
+import { Container, Row } from "react-bootstrap";
+import { useHistory, useLocation } from "react-router";
+import { Link } from "react-router-dom";
+
+import { HttpNetworkError } from "http/common";
+import { getHttpSearchClient } from "http/search";
+import { HttpTimelineInfo } from "http/timeline";
+
+import SearchInput from "../common/SearchInput";
+import UserAvatar from "../common/user/UserAvatar";
+
+const TimelineSearchResultItemView: React.FC<{
+ timeline: HttpTimelineInfo;
+}> = ({ timeline }) => {
+ const link = timeline.name.startsWith("@")
+ ? `users/${timeline.owner.username}`
+ : `timelines/${timeline.name}`;
+
+ return (
+ <div className="timeline-search-result-item my-2 p-3">
+ <h4>
+ <Link to={link} className="mb-2 text-primary">
+ {timeline.title}
+ <small className="ms-3 text-secondary">{timeline.name}</small>
+ </Link>
+ </h4>
+ <div>
+ <UserAvatar
+ username={timeline.owner.username}
+ className="timeline-search-result-item-avatar me-2"
+ />
+ {timeline.owner.nickname}
+ <small className="ms-3 text-secondary">
+ @{timeline.owner.username}
+ </small>
+ </div>
+ </div>
+ );
+};
+
+const SearchPage: React.FC = () => {
+ const { t } = useTranslation();
+
+ const history = useHistory();
+ const location = useLocation();
+ const searchParams = new URLSearchParams(location.search);
+ const queryParam = searchParams.get("q");
+
+ const [searchText, setSearchText] = React.useState<string>("");
+ const [state, setState] = React.useState<
+ HttpTimelineInfo[] | "init" | "loading" | "network-error" | "error"
+ >("init");
+
+ const [forceResearchKey, setForceResearchKey] = React.useState<number>(0);
+
+ React.useEffect(() => {
+ setState("init");
+ if (queryParam != null && queryParam.length > 0) {
+ setSearchText(queryParam);
+ setState("loading");
+ void getHttpSearchClient()
+ .searchTimelines(queryParam)
+ .then(
+ (ts) => {
+ setState(ts);
+ },
+ (e) => {
+ if (e instanceof HttpNetworkError) {
+ setState("network-error");
+ } else {
+ setState("error");
+ }
+ }
+ );
+ }
+ }, [queryParam, forceResearchKey]);
+
+ return (
+ <Container className="my-3">
+ <Row className="justify-content-center">
+ <SearchInput
+ className="col-12 col-sm-9 col-md-6"
+ value={searchText}
+ onChange={setSearchText}
+ loading={state === "loading"}
+ onButtonClick={() => {
+ if (queryParam === searchText) {
+ setForceResearchKey((old) => old + 1);
+ } else {
+ history.push(`/search?q=${searchText}`);
+ }
+ }}
+ />
+ </Row>
+ {(() => {
+ switch (state) {
+ case "init": {
+ if (queryParam == null || queryParam.length === 0) {
+ return <div>{t("searchPage.input")}</div>;
+ }
+ break;
+ }
+ case "loading": {
+ return <div>{t("searchPage.loading")}</div>;
+ }
+ case "network-error": {
+ return <div className="text-danger">{t("error.network")}</div>;
+ }
+ case "error": {
+ return <div className="text-danger">{t("error.unknown")}</div>;
+ }
+ default: {
+ if (state.length === 0) {
+ return <div>{t("searchPage.noResult")}</div>;
+ }
+ return state.map((t) => (
+ <TimelineSearchResultItemView key={t.name} timeline={t} />
+ ));
+ }
+ }
+ })()}
+ </Container>
+ );
+};
+
+export default SearchPage;
diff --git a/FrontEnd/src/views/search/search.sass b/FrontEnd/src/views/search/search.sass
new file mode 100644
index 00000000..83f297fe
--- /dev/null
+++ b/FrontEnd/src/views/search/search.sass
@@ -0,0 +1,13 @@
+.timeline-search-result-item
+ @extend .rounded
+ border: 1px solid
+ border-color: $gray-200
+ background: $gray-100
+ transition: all 0.3s
+ &:hover
+ border-color: $primary
+
+.timeline-search-result-item-avatar
+ width: 2em
+ height: 2em
+ border-radius: 50%
diff --git a/FrontEnd/src/views/settings/ChangeAvatarDialog.tsx b/FrontEnd/src/views/settings/ChangeAvatarDialog.tsx
new file mode 100644
index 00000000..338d2112
--- /dev/null
+++ b/FrontEnd/src/views/settings/ChangeAvatarDialog.tsx
@@ -0,0 +1,305 @@
+import React, { useState, useEffect } from "react";
+import { useTranslation } from "react-i18next";
+import { AxiosError } from "axios";
+import { Modal, Row, Button } from "react-bootstrap";
+
+import { UiLogicError } from "@/common";
+
+import { useUserLoggedIn } from "@/services/user";
+
+import { getHttpUserClient } from "http/user";
+
+import ImageCropper, { Clip, applyClipToImage } from "../common/ImageCropper";
+
+export interface ChangeAvatarDialogProps {
+ open: boolean;
+ close: () => void;
+}
+
+const ChangeAvatarDialog: React.FC<ChangeAvatarDialogProps> = (props) => {
+ const { t } = useTranslation();
+
+ const user = useUserLoggedIn();
+
+ const [file, setFile] = React.useState<File | null>(null);
+ const [fileUrl, setFileUrl] = React.useState<string | null>(null);
+ const [clip, setClip] = React.useState<Clip | null>(null);
+ const [cropImgElement, setCropImgElement] =
+ React.useState<HTMLImageElement | null>(null);
+ const [resultBlob, setResultBlob] = React.useState<Blob | null>(null);
+ const [resultUrl, setResultUrl] = React.useState<string | null>(null);
+
+ const [state, setState] = React.useState<
+ | "select"
+ | "crop"
+ | "processcrop"
+ | "preview"
+ | "uploading"
+ | "success"
+ | "error"
+ >("select");
+
+ const [message, setMessage] = useState<
+ string | { type: "custom"; text: string } | null
+ >("settings.dialogChangeAvatar.prompt.select");
+
+ const trueMessage =
+ message == null
+ ? null
+ : typeof message === "string"
+ ? t(message)
+ : message.text;
+
+ const closeDialog = props.close;
+
+ const close = React.useCallback((): void => {
+ if (!(state === "uploading")) {
+ closeDialog();
+ }
+ }, [state, closeDialog]);
+
+ useEffect(() => {
+ if (file != null) {
+ const url = URL.createObjectURL(file);
+ setClip(null);
+ setFileUrl(url);
+ setState("crop");
+ return () => {
+ URL.revokeObjectURL(url);
+ };
+ } else {
+ setFileUrl(null);
+ setState("select");
+ }
+ }, [file]);
+
+ React.useEffect(() => {
+ if (resultBlob != null) {
+ const url = URL.createObjectURL(resultBlob);
+ setResultUrl(url);
+ setState("preview");
+ return () => {
+ URL.revokeObjectURL(url);
+ };
+ } else {
+ setResultUrl(null);
+ }
+ }, [resultBlob]);
+
+ const onSelectFile = React.useCallback(
+ (e: React.ChangeEvent<HTMLInputElement>): void => {
+ const files = e.target.files;
+ if (files == null || files.length === 0) {
+ setFile(null);
+ } else {
+ setFile(files[0]);
+ }
+ },
+ []
+ );
+
+ const onCropNext = React.useCallback(() => {
+ if (
+ cropImgElement == null ||
+ clip == null ||
+ clip.width === 0 ||
+ file == null
+ ) {
+ throw new UiLogicError();
+ }
+
+ setState("processcrop");
+ void applyClipToImage(cropImgElement, clip, file.type).then((b) => {
+ setResultBlob(b);
+ });
+ }, [cropImgElement, clip, file]);
+
+ const onCropPrevious = React.useCallback(() => {
+ setFile(null);
+ setState("select");
+ }, []);
+
+ const onPreviewPrevious = React.useCallback(() => {
+ setResultBlob(null);
+ setState("crop");
+ }, []);
+
+ const upload = React.useCallback(() => {
+ if (resultBlob == null) {
+ throw new UiLogicError();
+ }
+
+ setState("uploading");
+ getHttpUserClient()
+ .putAvatar(user.username, resultBlob)
+ .then(
+ () => {
+ setState("success");
+ },
+ (e: unknown) => {
+ setState("error");
+ setMessage({ type: "custom", text: (e as AxiosError).message });
+ }
+ );
+ }, [user.username, resultBlob]);
+
+ const createPreviewRow = (): React.ReactElement => {
+ if (resultUrl == null) {
+ throw new UiLogicError();
+ }
+ return (
+ <Row className="justify-content-center">
+ <img
+ className="change-avatar-img"
+ src={resultUrl}
+ alt={t("settings.dialogChangeAvatar.previewImgAlt")}
+ />
+ </Row>
+ );
+ };
+
+ return (
+ <Modal show={props.open} onHide={close}>
+ <Modal.Header>
+ <Modal.Title> {t("settings.dialogChangeAvatar.title")}</Modal.Title>
+ </Modal.Header>
+ {(() => {
+ if (state === "select") {
+ return (
+ <>
+ <Modal.Body className="container">
+ <Row>{t("settings.dialogChangeAvatar.prompt.select")}</Row>
+ <Row>
+ <input type="file" accept="image/*" onChange={onSelectFile} />
+ </Row>
+ </Modal.Body>
+ <Modal.Footer>
+ <Button variant="secondary" onClick={close}>
+ {t("operationDialog.cancel")}
+ </Button>
+ </Modal.Footer>
+ </>
+ );
+ } else if (state === "crop") {
+ if (fileUrl == null) {
+ throw new UiLogicError();
+ }
+ return (
+ <>
+ <Modal.Body className="container">
+ <Row className="justify-content-center">
+ <ImageCropper
+ clip={clip}
+ onChange={setClip}
+ imageUrl={fileUrl}
+ imageElementCallback={setCropImgElement}
+ />
+ </Row>
+ <Row>{t("settings.dialogChangeAvatar.prompt.crop")}</Row>
+ </Modal.Body>
+ <Modal.Footer>
+ <Button variant="secondary" onClick={close}>
+ {t("operationDialog.cancel")}
+ </Button>
+ <Button variant="secondary" onClick={onCropPrevious}>
+ {t("operationDialog.previousStep")}
+ </Button>
+ <Button
+ color="primary"
+ onClick={onCropNext}
+ disabled={
+ cropImgElement == null || clip == null || clip.width === 0
+ }
+ >
+ {t("operationDialog.nextStep")}
+ </Button>
+ </Modal.Footer>
+ </>
+ );
+ } else if (state === "processcrop") {
+ return (
+ <>
+ <Modal.Body className="container">
+ <Row>
+ {t("settings.dialogChangeAvatar.prompt.processingCrop")}
+ </Row>
+ </Modal.Body>
+ <Modal.Footer>
+ <Button variant="secondary" onClick={close}>
+ {t("operationDialog.cancel")}
+ </Button>
+ <Button variant="secondary" onClick={onPreviewPrevious}>
+ {t("operationDialog.previousStep")}
+ </Button>
+ </Modal.Footer>
+ </>
+ );
+ } else if (state === "preview") {
+ return (
+ <>
+ <Modal.Body className="container">
+ {createPreviewRow()}
+ <Row>{t("settings.dialogChangeAvatar.prompt.preview")}</Row>
+ </Modal.Body>
+ <Modal.Footer>
+ <Button variant="secondary" onClick={close}>
+ {t("operationDialog.cancel")}
+ </Button>
+ <Button variant="secondary" onClick={onPreviewPrevious}>
+ {t("operationDialog.previousStep")}
+ </Button>
+ <Button variant="primary" onClick={upload}>
+ {t("settings.dialogChangeAvatar.upload")}
+ </Button>
+ </Modal.Footer>
+ </>
+ );
+ } else if (state === "uploading") {
+ return (
+ <>
+ <Modal.Body className="container">
+ {createPreviewRow()}
+ <Row>{t("settings.dialogChangeAvatar.prompt.uploading")}</Row>
+ </Modal.Body>
+ <Modal.Footer></Modal.Footer>
+ </>
+ );
+ } else if (state === "success") {
+ return (
+ <>
+ <Modal.Body className="container">
+ <Row className="p-4 text-success">
+ {t("operationDialog.success")}
+ </Row>
+ </Modal.Body>
+ <Modal.Footer>
+ <Button variant="success" onClick={close}>
+ {t("operationDialog.ok")}
+ </Button>
+ </Modal.Footer>
+ </>
+ );
+ } else {
+ return (
+ <>
+ <Modal.Body className="container">
+ {createPreviewRow()}
+ <Row className="text-danger">{trueMessage}</Row>
+ </Modal.Body>
+ <Modal.Footer>
+ <Button variant="secondary" onClick={close}>
+ {t("operationDialog.cancel")}
+ </Button>
+ <Button variant="primary" onClick={upload}>
+ {t("operationDialog.retry")}
+ </Button>
+ </Modal.Footer>
+ </>
+ );
+ }
+ })()}
+ </Modal>
+ );
+};
+
+export default ChangeAvatarDialog;
diff --git a/FrontEnd/src/views/settings/ChangeNicknameDialog.tsx b/FrontEnd/src/views/settings/ChangeNicknameDialog.tsx
new file mode 100644
index 00000000..e6420f36
--- /dev/null
+++ b/FrontEnd/src/views/settings/ChangeNicknameDialog.tsx
@@ -0,0 +1,32 @@
+import { getHttpUserClient } from "http/user";
+import { useUserLoggedIn } from "@/services/user";
+import React from "react";
+
+import OperationDialog from "../common/OperationDialog";
+
+export interface ChangeNicknameDialogProps {
+ open: boolean;
+ close: () => void;
+}
+
+const ChangeNicknameDialog: React.FC<ChangeNicknameDialogProps> = (props) => {
+ const user = useUserLoggedIn();
+
+ return (
+ <OperationDialog
+ open={props.open}
+ title="settings.dialogChangeNickname.title"
+ inputScheme={[
+ { type: "text", label: "settings.dialogChangeNickname.inputLabel" },
+ ]}
+ onProcess={([newNickname]) => {
+ return getHttpUserClient().patch(user.username, {
+ nickname: newNickname,
+ });
+ }}
+ close={props.close}
+ />
+ );
+};
+
+export default ChangeNicknameDialog;
diff --git a/FrontEnd/src/views/settings/ChangePasswordDialog.tsx b/FrontEnd/src/views/settings/ChangePasswordDialog.tsx
new file mode 100644
index 00000000..21eeeb09
--- /dev/null
+++ b/FrontEnd/src/views/settings/ChangePasswordDialog.tsx
@@ -0,0 +1,68 @@
+import React, { useState } from "react";
+import { useHistory } from "react-router";
+
+import { userService } from "@/services/user";
+
+import OperationDialog from "../common/OperationDialog";
+
+export interface ChangePasswordDialogProps {
+ open: boolean;
+ close: () => void;
+}
+
+const ChangePasswordDialog: React.FC<ChangePasswordDialogProps> = (props) => {
+ const history = useHistory();
+
+ const [redirect, setRedirect] = useState<boolean>(false);
+
+ return (
+ <OperationDialog
+ open={props.open}
+ title="settings.dialogChangePassword.title"
+ themeColor="danger"
+ inputPrompt="settings.dialogChangePassword.prompt"
+ inputScheme={[
+ {
+ type: "text",
+ label: "settings.dialogChangePassword.inputOldPassword",
+ password: true,
+ },
+ {
+ type: "text",
+ label: "settings.dialogChangePassword.inputNewPassword",
+ password: true,
+ },
+ {
+ type: "text",
+ label: "settings.dialogChangePassword.inputRetypeNewPassword",
+ password: true,
+ },
+ ]}
+ inputValidator={([oldPassword, newPassword, retypedNewPassword]) => {
+ const result: Record<number, string> = {};
+ if (oldPassword === "") {
+ result[0] = "settings.dialogChangePassword.errorEmptyOldPassword";
+ }
+ if (newPassword === "") {
+ result[1] = "settings.dialogChangePassword.errorEmptyNewPassword";
+ }
+ if (retypedNewPassword !== newPassword) {
+ result[2] = "settings.dialogChangePassword.errorRetypeNotMatch";
+ }
+ return result;
+ }}
+ onProcess={async ([oldPassword, newPassword]) => {
+ await userService.changePassword(oldPassword, newPassword);
+ setRedirect(true);
+ }}
+ close={() => {
+ props.close();
+ if (redirect) {
+ history.push("/login");
+ }
+ }}
+ />
+ );
+};
+
+export default ChangePasswordDialog;
diff --git a/FrontEnd/src/views/settings/index.tsx b/FrontEnd/src/views/settings/index.tsx
new file mode 100644
index 00000000..04a2777a
--- /dev/null
+++ b/FrontEnd/src/views/settings/index.tsx
@@ -0,0 +1,138 @@
+import React, { useState } from "react";
+import { useHistory } from "react-router";
+import { useTranslation } from "react-i18next";
+import { Container, Form, Row, Col, Button, Modal } from "react-bootstrap";
+
+import { useUser, userService } from "@/services/user";
+
+import ChangePasswordDialog from "./ChangePasswordDialog";
+import ChangeAvatarDialog from "./ChangeAvatarDialog";
+import ChangeNicknameDialog from "./ChangeNicknameDialog";
+
+const ConfirmLogoutDialog: React.FC<{
+ onClose: () => void;
+ onConfirm: () => void;
+}> = ({ onClose, onConfirm }) => {
+ const { t } = useTranslation();
+
+ return (
+ <Modal show centered onHide={onClose}>
+ <Modal.Header>
+ <Modal.Title className="text-danger">
+ {t("settings.dialogConfirmLogout.title")}
+ </Modal.Title>
+ </Modal.Header>
+ <Modal.Body>{t("settings.dialogConfirmLogout.prompt")}</Modal.Body>
+ <Modal.Footer>
+ <Button variant="secondary" onClick={onClose}>
+ {t("operationDialog.cancel")}
+ </Button>
+ <Button variant="danger" onClick={onConfirm}>
+ {t("operationDialog.confirm")}
+ </Button>
+ </Modal.Footer>
+ </Modal>
+ );
+};
+
+const SettingsPage: React.FC = (_) => {
+ const { i18n, t } = useTranslation();
+ const user = useUser();
+ const history = useHistory();
+
+ const [dialog, setDialog] = useState<
+ null | "changepassword" | "changeavatar" | "changenickname" | "logout"
+ >(null);
+
+ const language = i18n.language.slice(0, 2);
+
+ return (
+ <>
+ <Container>
+ {user ? (
+ <div className="cru-card my-3 py-3">
+ <h3 className="px-3 mb-3 text-primary">
+ {t("settings.subheaders.account")}
+ </h3>
+ <div
+ className="settings-item clickable first"
+ onClick={() => setDialog("changeavatar")}
+ >
+ {t("settings.changeAvatar")}
+ </div>
+ <div
+ className="settings-item clickable"
+ onClick={() => setDialog("changenickname")}
+ >
+ {t("settings.changeNickname")}
+ </div>
+ <div
+ className="settings-item clickable text-danger"
+ onClick={() => setDialog("changepassword")}
+ >
+ {t("settings.changePassword")}
+ </div>
+ <div
+ className="settings-item clickable text-danger"
+ onClick={() => {
+ setDialog("logout");
+ }}
+ >
+ {t("settings.logout")}
+ </div>
+ </div>
+ ) : null}
+ <div className="cru-card my-3 py-3">
+ <h3 className="px-3 mb-3 text-primary">
+ {t("settings.subheaders.customization")}
+ </h3>
+ <Row className="settings-item first mx-0">
+ <Col xs="12" sm="auto">
+ <div>{t("settings.languagePrimary")}</div>
+ <small className="d-block text-secondary">
+ {t("settings.languageSecondary")}
+ </small>
+ </Col>
+ <Col xs="auto" className="ms-auto">
+ <Form.Control
+ as="select"
+ value={language}
+ onChange={(e) => {
+ void i18n.changeLanguage(e.target.value);
+ }}
+ >
+ <option value="zh">中文</option>
+ <option value="en">English</option>
+ </Form.Control>
+ </Col>
+ </Row>
+ </div>
+ </Container>
+ {(() => {
+ switch (dialog) {
+ case "changepassword":
+ return <ChangePasswordDialog open close={() => setDialog(null)} />;
+ case "logout":
+ return (
+ <ConfirmLogoutDialog
+ onClose={() => setDialog(null)}
+ onConfirm={() => {
+ void userService.logout().then(() => {
+ history.push("/");
+ });
+ }}
+ />
+ );
+ case "changeavatar":
+ return <ChangeAvatarDialog open close={() => setDialog(null)} />;
+ case "changenickname":
+ return <ChangeNicknameDialog open close={() => setDialog(null)} />;
+ default:
+ return null;
+ }
+ })()}
+ </>
+ );
+};
+
+export default SettingsPage;
diff --git a/FrontEnd/src/views/settings/settings.sass b/FrontEnd/src/views/settings/settings.sass
new file mode 100644
index 00000000..8c6d24b8
--- /dev/null
+++ b/FrontEnd/src/views/settings/settings.sass
@@ -0,0 +1,14 @@
+.settings-item
+ padding: 0.5em 1em
+ transition: background 0.3s
+ border-bottom: 1px solid $gray-200
+
+ &.first
+ border-top: 1px solid $gray-200
+
+ &.clickable
+ cursor: pointer
+
+ &:hover
+ background: $gray-300
+
diff --git a/FrontEnd/src/views/timeline-common/CollapseButton.tsx b/FrontEnd/src/views/timeline-common/CollapseButton.tsx
new file mode 100644
index 00000000..12a3b710
--- /dev/null
+++ b/FrontEnd/src/views/timeline-common/CollapseButton.tsx
@@ -0,0 +1,23 @@
+import React from "react";
+import classnames from "classnames";
+
+const CollapseButton: React.FC<{
+ collapse: boolean;
+ onClick: () => void;
+ className?: string;
+ style?: React.CSSProperties;
+}> = ({ collapse, onClick, className, style }) => {
+ return (
+ <i
+ onClick={onClick}
+ className={classnames(
+ collapse ? "bi-arrows-angle-expand" : "bi-arrows-angle-contract",
+ "text-primary icon-button",
+ className
+ )}
+ style={style}
+ />
+ );
+};
+
+export default CollapseButton;
diff --git a/FrontEnd/src/views/timeline-common/ConnectionStatusBadge.tsx b/FrontEnd/src/views/timeline-common/ConnectionStatusBadge.tsx
new file mode 100644
index 00000000..df43d8d2
--- /dev/null
+++ b/FrontEnd/src/views/timeline-common/ConnectionStatusBadge.tsx
@@ -0,0 +1,39 @@
+import React from "react";
+import classnames from "classnames";
+import { HubConnectionState } from "@microsoft/signalr";
+import { useTranslation } from "react-i18next";
+
+export interface ConnectionStatusBadgeProps {
+ status: HubConnectionState;
+ className?: string;
+ style?: React.CSSProperties;
+}
+
+const classNameMap: Record<HubConnectionState, string> = {
+ Connected: "success",
+ Connecting: "warning",
+ Disconnected: "danger",
+ Disconnecting: "warning",
+ Reconnecting: "warning",
+};
+
+const ConnectionStatusBadge: React.FC<ConnectionStatusBadgeProps> = (props) => {
+ const { status, className, style } = props;
+
+ const { t } = useTranslation();
+
+ return (
+ <div
+ className={classnames(
+ "connection-status-badge",
+ classNameMap[status],
+ className
+ )}
+ style={style}
+ >
+ {t(`connectionState.${status}`)}
+ </div>
+ );
+};
+
+export default ConnectionStatusBadge;
diff --git a/FrontEnd/src/views/timeline-common/MarkdownPostEdit.tsx b/FrontEnd/src/views/timeline-common/MarkdownPostEdit.tsx
new file mode 100644
index 00000000..1514d28f
--- /dev/null
+++ b/FrontEnd/src/views/timeline-common/MarkdownPostEdit.tsx
@@ -0,0 +1,205 @@
+import React from "react";
+import classnames from "classnames";
+import { Form, Spinner } from "react-bootstrap";
+import { useTranslation } from "react-i18next";
+import { Prompt } from "react-router";
+
+import { getHttpTimelineClient, HttpTimelinePostInfo } from "http/timeline";
+
+import FlatButton from "../common/FlatButton";
+import TabPages from "../common/TabPages";
+import TimelinePostBuilder from "@/services/TimelinePostBuilder";
+import ConfirmDialog from "../common/ConfirmDialog";
+
+export interface MarkdownPostEditProps {
+ timeline: string;
+ onPosted: (post: HttpTimelinePostInfo) => void;
+ onPostError: () => void;
+ onClose: () => void;
+ className?: string;
+ style?: React.CSSProperties;
+}
+
+const MarkdownPostEdit: React.FC<MarkdownPostEditProps> = ({
+ timeline: timelineName,
+ onPosted,
+ onClose,
+ onPostError,
+ className,
+ style,
+}) => {
+ const { t } = useTranslation();
+
+ const [canLeave, setCanLeave] = React.useState<boolean>(true);
+
+ const [process, setProcess] = React.useState<boolean>(false);
+
+ const [showLeaveConfirmDialog, setShowLeaveConfirmDialog] =
+ React.useState<boolean>(false);
+
+ const [text, _setText] = React.useState<string>("");
+ const [images, _setImages] = React.useState<{ file: File; url: string }[]>(
+ []
+ );
+ const [previewHtml, _setPreviewHtml] = React.useState<string>("");
+
+ const _builder = React.useRef<TimelinePostBuilder | null>(null);
+
+ const getBuilder = (): TimelinePostBuilder => {
+ if (_builder.current == null) {
+ const builder = new TimelinePostBuilder(() => {
+ setCanLeave(builder.isEmpty);
+ _setText(builder.text);
+ _setImages(builder.images);
+ _setPreviewHtml(builder.renderHtml());
+ });
+ _builder.current = builder;
+ }
+ return _builder.current;
+ };
+
+ const canSend = text.length > 0;
+
+ React.useEffect(() => {
+ return () => {
+ getBuilder().dispose();
+ };
+ }, []);
+
+ React.useEffect(() => {
+ window.onbeforeunload = (): unknown => {
+ if (!canLeave) {
+ return t("timeline.confirmLeave");
+ }
+ };
+
+ return () => {
+ window.onbeforeunload = null;
+ };
+ }, [canLeave, t]);
+
+ const send = async (): Promise<void> => {
+ setProcess(true);
+ try {
+ const dataList = await getBuilder().build();
+ const post = await getHttpTimelineClient().postPost(timelineName, {
+ dataList,
+ });
+ onPosted(post);
+ onClose();
+ } catch (e) {
+ setProcess(false);
+ onPostError();
+ }
+ };
+
+ return (
+ <>
+ <Prompt when={!canLeave} message={t("timeline.confirmLeave")} />
+ <TabPages
+ className={className}
+ style={style}
+ pageContainerClassName="py-2"
+ actions={
+ process ? (
+ <Spinner variant="primary" animation="border" size="sm" />
+ ) : (
+ <>
+ <FlatButton
+ className="me-2"
+ variant="danger"
+ onClick={() => {
+ if (canLeave) {
+ onClose();
+ } else {
+ setShowLeaveConfirmDialog(true);
+ }
+ }}
+ >
+ {t("operationDialog.cancel")}
+ </FlatButton>
+ <FlatButton onClick={send} disabled={!canSend}>
+ {t("timeline.send")}
+ </FlatButton>
+ </>
+ )
+ }
+ pages={[
+ {
+ id: "text",
+ tabText: "edit",
+ page: (
+ <Form.Control
+ as="textarea"
+ value={text}
+ disabled={process}
+ onChange={(event) => {
+ getBuilder().setMarkdownText(event.currentTarget.value);
+ }}
+ />
+ ),
+ },
+ {
+ id: "images",
+ tabText: "image",
+ page: (
+ <div className="timeline-markdown-post-edit-page">
+ {images.map((image, index) => (
+ <div
+ key={image.url}
+ className="timeline-markdown-post-edit-image-container"
+ >
+ <img
+ src={image.url}
+ className="timeline-markdown-post-edit-image"
+ />
+ <i
+ className={classnames(
+ "bi-trash text-danger icon-button timeline-markdown-post-edit-image-delete-button",
+ process && "d-none"
+ )}
+ onClick={() => {
+ getBuilder().deleteImage(index);
+ }}
+ />
+ </div>
+ ))}
+ <Form.Control
+ type="file"
+ accept="image/jpeg,image/jpg,image/png,image/gif,image/webp"
+ onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
+ const { files } = event.currentTarget;
+ if (files != null && files.length !== 0) {
+ getBuilder().appendImage(files[0]);
+ }
+ }}
+ disabled={process}
+ />
+ </div>
+ ),
+ },
+ {
+ id: "preview",
+ tabText: "preview",
+ page: (
+ <div
+ className="markdown-container timeline-markdown-post-edit-page"
+ dangerouslySetInnerHTML={{ __html: previewHtml }}
+ />
+ ),
+ },
+ ]}
+ />
+ {showLeaveConfirmDialog && (
+ <ConfirmDialog
+ onClose={() => setShowLeaveConfirmDialog(false)}
+ onConfirm={onClose}
+ title="timeline.dropDraft"
+ body="timeline.confirmLeave"
+ />
+ )}
+ </>
+ );
+};
+
+export default MarkdownPostEdit;
diff --git a/FrontEnd/src/views/timeline-common/PostPropertyChangeDialog.tsx b/FrontEnd/src/views/timeline-common/PostPropertyChangeDialog.tsx
new file mode 100644
index 00000000..21c5272e
--- /dev/null
+++ b/FrontEnd/src/views/timeline-common/PostPropertyChangeDialog.tsx
@@ -0,0 +1,36 @@
+import React from "react";
+
+import { getHttpTimelineClient, HttpTimelinePostInfo } from "http/timeline";
+
+import OperationDialog from "../common/OperationDialog";
+
+function PostPropertyChangeDialog(props: {
+ onClose: () => void;
+ post: HttpTimelinePostInfo;
+ onSuccess: (post: HttpTimelinePostInfo) => void;
+}): React.ReactElement | null {
+ const { onClose, post, onSuccess } = props;
+
+ return (
+ <OperationDialog
+ title="timeline.changePostPropertyDialog.title"
+ close={onClose}
+ open
+ inputScheme={[
+ {
+ label: "timeline.changePostPropertyDialog.time",
+ type: "datetime",
+ initValue: post.time,
+ },
+ ]}
+ onProcess={([time]) => {
+ return getHttpTimelineClient().patchPost(post.timelineName, post.id, {
+ time: time === "" ? undefined : new Date(time).toISOString(),
+ });
+ }}
+ onSuccessAndClose={onSuccess}
+ />
+ );
+}
+
+export default PostPropertyChangeDialog;
diff --git a/FrontEnd/src/views/timeline-common/Timeline.tsx b/FrontEnd/src/views/timeline-common/Timeline.tsx
new file mode 100644
index 00000000..40619e64
--- /dev/null
+++ b/FrontEnd/src/views/timeline-common/Timeline.tsx
@@ -0,0 +1,143 @@
+import React from "react";
+import { HubConnectionState } from "@microsoft/signalr";
+
+import {
+ HttpForbiddenError,
+ HttpNetworkError,
+ HttpNotFoundError,
+} from "http/common";
+import { getHttpTimelineClient, HttpTimelinePostInfo } from "http/timeline";
+
+import { getTimelinePostUpdate$ } from "@/services/timeline";
+
+import TimelinePagedPostListView from "./TimelinePagedPostListView";
+import TimelineTop from "./TimelineTop";
+import TimelineLoading from "./TimelineLoading";
+
+export interface TimelineProps {
+ className?: string;
+ style?: React.CSSProperties;
+ timelineName?: string;
+ reloadKey: number;
+ onReload: () => void;
+ onConnectionStateChanged?: (state: HubConnectionState) => void;
+}
+
+const Timeline: React.FC<TimelineProps> = (props) => {
+ const { timelineName, className, style, reloadKey } = props;
+
+ const [state, setState] = React.useState<
+ "loading" | "loaded" | "offline" | "notexist" | "forbid" | "error"
+ >("loading");
+ const [posts, setPosts] = React.useState<HttpTimelinePostInfo[]>([]);
+
+ React.useEffect(() => {
+ setState("loading");
+ setPosts([]);
+ }, [timelineName]);
+
+ const onReload = React.useRef<() => void>(props.onReload);
+
+ React.useEffect(() => {
+ onReload.current = props.onReload;
+ }, [props.onReload]);
+
+ const onConnectionStateChanged = React.useRef<
+ ((state: HubConnectionState) => void) | null
+ >(null);
+
+ React.useEffect(() => {
+ onConnectionStateChanged.current = props.onConnectionStateChanged ?? null;
+ }, [props.onConnectionStateChanged]);
+
+ React.useEffect(() => {
+ if (timelineName != null && state === "loaded") {
+ const timelinePostUpdate$ = getTimelinePostUpdate$(timelineName);
+ const subscription = timelinePostUpdate$.subscribe(
+ ({ update, state }) => {
+ if (update) {
+ onReload.current();
+ }
+ onConnectionStateChanged.current?.(state);
+ }
+ );
+ return () => {
+ subscription.unsubscribe();
+ };
+ }
+ }, [timelineName, state]);
+
+ React.useEffect(() => {
+ if (timelineName != null) {
+ let subscribe = true;
+
+ void getHttpTimelineClient()
+ .listPost(timelineName)
+ .then(
+ (data) => {
+ if (subscribe) {
+ setState("loaded");
+ setPosts(data);
+ }
+ },
+ (error) => {
+ if (error instanceof HttpNetworkError) {
+ setState("offline");
+ } else if (error instanceof HttpForbiddenError) {
+ setState("forbid");
+ } else if (error instanceof HttpNotFoundError) {
+ setState("notexist");
+ } else {
+ console.error(error);
+ setState("error");
+ }
+ }
+ );
+
+ return () => {
+ subscribe = false;
+ };
+ }
+ }, [timelineName, reloadKey]);
+
+ switch (state) {
+ case "loading":
+ return <TimelineLoading />;
+ case "offline":
+ return (
+ <div className={className} style={style}>
+ Offline.
+ </div>
+ );
+ case "notexist":
+ return (
+ <div className={className} style={style}>
+ Not exist.
+ </div>
+ );
+ case "forbid":
+ return (
+ <div className={className} style={style}>
+ Forbid.
+ </div>
+ );
+ case "error":
+ return (
+ <div className={className} style={style}>
+ Error.
+ </div>
+ );
+ default:
+ return (
+ <>
+ <TimelineTop height={40} />
+ <TimelinePagedPostListView
+ posts={posts}
+ onReload={onReload.current}
+ />
+ </>
+ );
+ }
+};
+
+export default Timeline;
diff --git a/FrontEnd/src/views/timeline-common/TimelineDateLabel.tsx b/FrontEnd/src/views/timeline-common/TimelineDateLabel.tsx
new file mode 100644
index 00000000..80968ee2
--- /dev/null
+++ b/FrontEnd/src/views/timeline-common/TimelineDateLabel.tsx
@@ -0,0 +1,19 @@
+import React from "react";
+import TimelineLine from "./TimelineLine";
+
+export interface TimelineDateItemProps {
+ date: Date;
+}
+
+const TimelineDateLabel: React.FC<TimelineDateItemProps> = ({ date }) => {
+ return (
+ <div className="timeline-date-item">
+ <TimelineLine center="none" />
+ <div className="timeline-date-item-badge">
+ {date.toLocaleDateString()}
+ </div>
+ </div>
+ );
+};
+
+export default TimelineDateLabel;
diff --git a/FrontEnd/src/views/timeline-common/TimelineLine.tsx b/FrontEnd/src/views/timeline-common/TimelineLine.tsx
new file mode 100644
index 00000000..0a828b32
--- /dev/null
+++ b/FrontEnd/src/views/timeline-common/TimelineLine.tsx
@@ -0,0 +1,51 @@
+import React from "react";
+import classnames from "classnames";
+
+export interface TimelineLineProps {
+ current?: boolean;
+ startSegmentLength?: string | number;
+ center: "node" | "loading" | "none";
+ className?: string;
+ style?: React.CSSProperties;
+}
+
+const TimelineLine: React.FC<TimelineLineProps> = ({
+ startSegmentLength,
+ center,
+ current,
+ className,
+ style,
+}) => {
+ return (
+ <div
+ className={classnames(
+ "timeline-line",
+ current && "current",
+ center === "loading" && "loading",
+ className
+ )}
+ style={style}
+ >
+ <div className="segment start" style={{ height: startSegmentLength }} />
+ {center !== "none" ? (
+ <div className="node-container">
+ <div className="node"></div>
+ {center === "loading" ? (
+ <svg className="node-loading-edge" viewBox="0 0 100 100">
+ <path
+ d="M 50,10 A 40 40 45 0 1 78.28,21.72"
+ stroke="currentcolor"
+ strokeLinecap="square"
+ strokeWidth="8"
+ />
+ </svg>
+ ) : null}
+ </div>
+ ) : null}
+ {center !== "loading" ? <div className="segment end"></div> : null}
+ {current && <div className="segment current-end" />}
+ </div>
+ );
+};
+
+export default TimelineLine;
diff --git a/FrontEnd/src/views/timeline-common/TimelineLoading.tsx b/FrontEnd/src/views/timeline-common/TimelineLoading.tsx
new file mode 100644
index 00000000..fc42f4b4
--- /dev/null
+++ b/FrontEnd/src/views/timeline-common/TimelineLoading.tsx
@@ -0,0 +1,18 @@
+import React from "react";
+
+import TimelineTop from "./TimelineTop";
+
+const TimelineLoading: React.FC = () => {
+ return (
+ <TimelineTop
+ className="timeline-top-loading-enter"
+ height={100}
+ lineProps={{
+ center: "loading",
+ startSegmentLength: 56,
+ }}
+ />
+ );
+};
+
+export default TimelineLoading;
diff --git a/FrontEnd/src/views/timeline-common/TimelineMember.tsx b/FrontEnd/src/views/timeline-common/TimelineMember.tsx
new file mode 100644
index 00000000..3d4de8b8
--- /dev/null
+++ b/FrontEnd/src/views/timeline-common/TimelineMember.tsx
@@ -0,0 +1,195 @@
+import React, { useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Container, ListGroup, Modal, Row, Col, Button } from "react-bootstrap";
+
+import { convertI18nText, I18nText } from "@/common";
+
+import { HttpUser } from "http/user";
+import { getHttpSearchClient } from "http/search";
+
+import SearchInput from "../common/SearchInput";
+import UserAvatar from "../common/user/UserAvatar";
+import { getHttpTimelineClient, HttpTimelineInfo } from "http/timeline";
+
+const TimelineMemberItem: React.FC<{
+ user: HttpUser;
+ add?: boolean;
+ onAction?: (username: string) => void;
+}> = ({ user, add, onAction }) => {
+ const { t } = useTranslation();
+
+ return (
+ <ListGroup.Item className="container">
+ <Row>
+ <Col xs="auto">
+ <UserAvatar username={user.username} className="avatar small" />
+ </Col>
+ <Col>
+ <Row>{user.nickname}</Row>
+ <Row>
+ <small>{"@" + user.username}</small>
+ </Row>
+ </Col>
+ {onAction ? (
+ <Col xs="auto">
+ <Button
+ variant={add ? "success" : "danger"}
+ onClick={() => {
+ onAction(user.username);
+ }}
+ >
+ {t(`timeline.member.${add ? "add" : "remove"}`)}
+ </Button>
+ </Col>
+ ) : null}
+ </Row>
+ </ListGroup.Item>
+ );
+};
+
+const TimelineMemberUserSearch: React.FC<{
+ timeline: HttpTimelineInfo;
+ onChange: () => void;
+}> = ({ timeline, onChange }) => {
+ const { t } = useTranslation();
+
+ const [userSearchText, setUserSearchText] = useState<string>("");
+ const [userSearchState, setUserSearchState] = useState<
+ | {
+ type: "users";
+ data: HttpUser[];
+ }
+ | { type: "error"; data: I18nText }
+ | { type: "loading" }
+ | { type: "init" }
+ >({ type: "init" });
+
+ return (
+ <>
+ <SearchInput
+ className="mt-3"
+ value={userSearchText}
+ onChange={(v) => {
+ setUserSearchText(v);
+ }}
+ loading={userSearchState.type === "loading"}
+ onButtonClick={() => {
+ if (userSearchText === "") {
+ setUserSearchState({
+ type: "error",
+ data: "login.emptyUsername",
+ });
+ return;
+ }
+ setUserSearchState({ type: "loading" });
+ getHttpSearchClient()
+ .searchUsers(userSearchText)
+ .then(
+ (users) => {
+ users = users.filter(
+ (user) =>
+ timeline.members.findIndex(
+ (m) => m.username === user.username
+ ) === -1 && timeline.owner.username !== user.username
+ );
+ setUserSearchState({ type: "users", data: users });
+ },
+ (e) => {
+ setUserSearchState({
+ type: "error",
+ data: { type: "custom", value: String(e) },
+ });
+ }
+ );
+ }}
+ />
+ {(() => {
+ if (userSearchState.type === "users") {
+ const users = userSearchState.data;
+ if (users.length === 0) {
+ return <div>{t("timeline.member.noUserAvailableToAdd")}</div>;
+ } else {
+ return (
+ <ListGroup className="mt-2">
+ {users.map((user) => (
+ <TimelineMemberItem
+ key={user.username}
+ user={user}
+ add
+ onAction={() => {
+ void getHttpTimelineClient()
+ .memberPut(timeline.name, user.username)
+ .then(() => {
+ setUserSearchText("");
+ setUserSearchState({ type: "init" });
+ onChange();
+ });
+ }}
+ />
+ ))}
+ </ListGroup>
+ );
+ }
+ } else if (userSearchState.type === "error") {
+ return (
+ <div className="text-danger">
+ {convertI18nText(userSearchState.data, t)}
+ </div>
+ );
+ }
+ })()}
+ </>
+ );
+};
+
+export interface TimelineMemberProps {
+ timeline: HttpTimelineInfo;
+ onChange: () => void;
+}
+
+const TimelineMember: React.FC<TimelineMemberProps> = (props) => {
+ const { timeline, onChange } = props;
+ const members = [timeline.owner, ...timeline.members];
+
+ return (
+ <Container className="px-4 py-3">
+ <ListGroup>
+ {members.map((member, index) => (
+ <TimelineMemberItem
+ key={member.username}
+ user={member}
+ onAction={
+ timeline.manageable && index !== 0
+ ? () => {
+ void getHttpTimelineClient()
+ .memberDelete(timeline.name, member.username)
+ .then(onChange);
+ }
+ : undefined
+ }
+ />
+ ))}
+ </ListGroup>
+ {timeline.manageable ? (
+ <TimelineMemberUserSearch timeline={timeline} onChange={onChange} />
+ ) : null}
+ </Container>
+ );
+};
+
+export default TimelineMember;
+
+export interface TimelineMemberDialogProps extends TimelineMemberProps {
+ open: boolean;
+ onClose: () => void;
+}
+
+export const TimelineMemberDialog: React.FC<TimelineMemberDialogProps> = (
+ props
+) => {
+ return (
+ <Modal show centered onHide={props.onClose}>
+ <TimelineMember {...props} />
+ </Modal>
+ );
+};
diff --git a/FrontEnd/src/views/timeline-common/TimelinePageCardTemplate.tsx b/FrontEnd/src/views/timeline-common/TimelinePageCardTemplate.tsx
new file mode 100644
index 00000000..038ea3ab
--- /dev/null
+++ b/FrontEnd/src/views/timeline-common/TimelinePageCardTemplate.tsx
@@ -0,0 +1,158 @@
+import React from "react";
+import classnames from "classnames";
+import { useTranslation } from "react-i18next";
+
+import { getHttpHighlightClient } from "http/highlight";
+import { getHttpBookmarkClient } from "http/bookmark";
+
+import { useUser } from "@/services/user";
+import { pushAlert } from "@/services/alert";
+import { timelineVisibilityTooltipTranslationMap } from "@/services/timeline";
+
+import { useIsSmallScreen } from "@/utilities/mediaQuery";
+
+import { TimelinePageCardProps } from "./TimelinePageTemplate";
+
+import CollapseButton from "./CollapseButton";
+import { TimelineMemberDialog } from "./TimelineMember";
+import TimelinePropertyChangeDialog from "./TimelinePropertyChangeDialog";
+import ConnectionStatusBadge from "./ConnectionStatusBadge";
+import { MenuItems, PopupMenu } from "../common/Menu";
+import FullPage from "../common/FullPage";
+
+export interface TimelineCardTemplateProps extends TimelinePageCardProps {
+ infoArea: React.ReactElement;
+ manageItems?: MenuItems;
+ dialog: string | "property" | "member" | null;
+ setDialog: (dialog: "property" | "member" | null) => void;
+}
+
+const TimelinePageCardTemplate: React.FC<TimelineCardTemplateProps> = ({
+ timeline,
+ collapse,
+ toggleCollapse,
+ infoArea,
+ manageItems,
+ connectionStatus,
+ onReload,
+ className,
+ dialog,
+ setDialog,
+}) => {
+ const { t } = useTranslation();
+
+ const isSmallScreen = useIsSmallScreen();
+
+ const user = useUser();
+
+ const content = (
+ <>
+ {infoArea}
+ <p className="mb-0">{timeline.description}</p>
+ <small className="mt-1 d-block">
+ {t(timelineVisibilityTooltipTranslationMap[timeline.visibility])}
+ </small>
+ <div className="text-end mt-2">
+ <i
+ className={classnames(
+ timeline.isHighlight ? "bi-star-fill" : "bi-star",
+ "icon-button text-yellow me-3"
+ )}
+ onClick={
+ user?.hasHighlightTimelineAdministrationPermission
+ ? () => {
+ getHttpHighlightClient()
+ [timeline.isHighlight ? "delete" : "put"](timeline.name)
+ .then(onReload, () => {
+ pushAlert({
+ message: timeline.isHighlight
+ ? "timeline.removeHighlightFail"
+ : "timeline.addHighlightFail",
+ type: "danger",
+ });
+ });
+ }
+ : undefined
+ }
+ />
+ {user != null ? (
+ <i
+ className={classnames(
+ timeline.isBookmark ? "bi-bookmark-fill" : "bi-bookmark",
+ "icon-button text-yellow me-3"
+ )}
+ onClick={() => {
+ getHttpBookmarkClient()
+ [timeline.isBookmark ? "delete" : "put"](timeline.name)
+ .then(onReload, () => {
+ pushAlert({
+ message: timeline.isBookmark
+ ? "timeline.removeBookmarkFail"
+ : "timeline.addBookmarkFail",
+ type: "danger",
+ });
+ });
+ }}
+ />
+ ) : null}
+ <i
+ className={"icon-button bi-people text-primary me-3"}
+ onClick={() => setDialog("member")}
+ />
+ {manageItems != null ? (
+ <PopupMenu items={manageItems}>
+ <i className="icon-button bi-three-dots-vertical text-primary" />
+ </PopupMenu>
+ ) : null}
+ </div>
+ </>
+ );
+
+ return (
+ <>
+ <div
+ className={classnames("cru-card p-2 clearfix", className)}
+ style={{ zIndex: collapse ? 1029 : 1031 }}
+ >
+ <div className="float-end d-flex align-items-center">
+ <ConnectionStatusBadge status={connectionStatus} className="me-2" />
+ <CollapseButton collapse={collapse} onClick={toggleCollapse} />
+ </div>
+ {isSmallScreen ? (
+ <FullPage
+ onBack={toggleCollapse}
+ show={!collapse}
+ contentContainerClassName="p-2"
+ >
+ {content}
+ </FullPage>
+ ) : (
+ <div style={{ display: collapse ? "none" : "block" }}>{content}</div>
+ )}
+ </div>
+ {(() => {
+ if (dialog === "member") {
+ return (
+ <TimelineMemberDialog
+ timeline={timeline}
+ onClose={() => setDialog(null)}
+ open
+ onChange={onReload}
+ />
+ );
+ } else if (dialog === "property") {
+ return (
+ <TimelinePropertyChangeDialog
+ timeline={timeline}
+ close={() => setDialog(null)}
+ open
+ onChange={onReload}
+ />
+ );
+ }
+ })()}
+ </>
+ );
+};
+
+export default TimelinePageCardTemplate;
diff --git a/FrontEnd/src/views/timeline-common/TimelinePageTemplate.tsx b/FrontEnd/src/views/timeline-common/TimelinePageTemplate.tsx
new file mode 100644
index 00000000..44926cc6
--- /dev/null
+++ b/FrontEnd/src/views/timeline-common/TimelinePageTemplate.tsx
@@ -0,0 +1,190 @@
+import React from "react";
+import { useTranslation } from "react-i18next";
+import { Container } from "react-bootstrap";
+import { HubConnectionState } from "@microsoft/signalr";
+
+import { HttpNetworkError, HttpNotFoundError } from "http/common";
+import { getHttpTimelineClient, HttpTimelineInfo } from "http/timeline";
+
+import { getAlertHost } from "@/services/alert";
+
+import Timeline from "./Timeline";
+import TimelinePostEdit from "./TimelinePostEdit";
+
+import useReverseScrollPositionRemember from "@/utilities/useReverseScrollPositionRemember";
+import { generatePalette, setPalette } from "@/palette";
+
+export interface TimelinePageCardProps {
+ timeline: HttpTimelineInfo;
+ collapse: boolean;
+ toggleCollapse: () => void;
+ connectionStatus: HubConnectionState;
+ className?: string;
+ onReload: () => void;
+}
+
+export interface TimelinePageTemplateProps {
+ timelineName: string;
+ notFoundI18nKey: string;
+ reloadKey: number;
+ onReload: () => void;
+ CardComponent: React.ComponentType<TimelinePageCardProps>;
+}
+
+const TimelinePageTemplate: React.FC<TimelinePageTemplateProps> = (props) => {
+ const { timelineName, reloadKey, onReload, CardComponent } = props;
+
+ const { t } = useTranslation();
+
+ const [state, setState] = React.useState<
+ "loading" | "done" | "offline" | "notexist" | "error"
+ >("loading");
+ const [timeline, setTimeline] = React.useState<HttpTimelineInfo | null>(null);
+
+ const [connectionStatus, setConnectionStatus] =
+ React.useState<HubConnectionState>(HubConnectionState.Connecting);
+
+ useReverseScrollPositionRemember();
+
+ React.useEffect(() => {
+ setState("loading");
+ setTimeline(null);
+ }, [timelineName]);
+
+ React.useEffect(() => {
+ let subscribe = true;
+ void getHttpTimelineClient()
+ .getTimeline(timelineName)
+ .then(
+ (data) => {
+ if (subscribe) {
+ setState("done");
+ setTimeline(data);
+ }
+ },
+ (error) => {
+ if (subscribe) {
+ if (error instanceof HttpNetworkError) {
+ setState("offline");
+ } else if (error instanceof HttpNotFoundError) {
+ setState("notexist");
+ } else {
+ console.error(error);
+ setState("error");
+ }
+ setTimeline(null);
+ }
+ }
+ );
+ return () => {
+ subscribe = false;
+ };
+ }, [timelineName, reloadKey]);
+
+ React.useEffect(() => {
+ if (timeline != null && timeline.color != null) {
+ return setPalette(generatePalette({ primary: timeline.color }));
+ }
+ }, [timeline]);
+
+ const [bottomSpaceHeight, setBottomSpaceHeight] = React.useState<number>(0);
+
+ const [timelineReloadKey, setTimelineReloadKey] = React.useState<number>(0);
+
+ const reloadTimeline = (): void => {
+ setTimelineReloadKey((old) => old + 1);
+ };
+
+ const onPostEditHeightChange = React.useCallback((height: number): void => {
+ setBottomSpaceHeight(height);
+ if (height === 0) {
+ const alertHost = getAlertHost();
+ if (alertHost != null) {
+ alertHost.style.removeProperty("margin-bottom");
+ }
+ } else {
+ const alertHost = getAlertHost();
+ if (alertHost != null) {
+ alertHost.style.marginBottom = `${height}px`;
+ }
+ }
+ }, []);
+
+ const cardCollapseLocalStorageKey = `timeline.${timelineName}.cardCollapse`;
+
+ const [cardCollapse, setCardCollapse] = React.useState<boolean>(true);
+
+ React.useEffect(() => {
+ const savedCollapse = window.localStorage.getItem(
+ cardCollapseLocalStorageKey
+ );
+ setCardCollapse(savedCollapse == null ? true : savedCollapse === "true");
+ }, [cardCollapseLocalStorageKey]);
+
+ const toggleCardCollapse = (): void => {
+ const newState = !cardCollapse;
+ setCardCollapse(newState);
+ window.localStorage.setItem(
+ cardCollapseLocalStorageKey,
+ newState.toString()
+ );
+ };
+
+ return (
+ <>
+ {timeline != null ? (
+ <CardComponent
+ className="timeline-template-card"
+ timeline={timeline}
+ collapse={cardCollapse}
+ toggleCollapse={toggleCardCollapse}
+ onReload={onReload}
+ connectionStatus={connectionStatus}
+ />
+ ) : null}
+ <Container
+ className="px-0"
+ style={{
+ minHeight: `calc(100vh - ${56 + bottomSpaceHeight}px)`,
+ }}
+ >
+ {(() => {
+ if (state === "offline") {
+ // TODO: i18n
+ return <p className="text-danger">Offline!</p>;
+ } else if (state === "notexist") {
+ return <p className="text-danger">{t(props.notFoundI18nKey)}</p>;
+ } else if (state === "error") {
+ // TODO: i18n
+ return <p className="text-danger">Error!</p>;
+ } else {
+ return (
+ <Timeline
+ timelineName={timeline?.name}
+ reloadKey={timelineReloadKey}
+ onReload={reloadTimeline}
+ onConnectionStateChanged={setConnectionStatus}
+ />
+ );
+ }
+ })()}
+ </Container>
+ {timeline != null && timeline.postable ? (
+ <>
+ <div
+ style={{ height: bottomSpaceHeight }}
+ className="flex-fix-length"
+ />
+ <TimelinePostEdit
+ className="fixed-bottom"
+ timeline={timeline}
+ onHeightChange={onPostEditHeightChange}
+ onPosted={reloadTimeline}
+ />
+ </>
+ ) : null}
+ </>
+ );
+};
+
+export default TimelinePageTemplate;
diff --git a/FrontEnd/src/views/timeline-common/TimelinePagedPostListView.tsx b/FrontEnd/src/views/timeline-common/TimelinePagedPostListView.tsx
new file mode 100644
index 00000000..d569a2d7
--- /dev/null
+++ b/FrontEnd/src/views/timeline-common/TimelinePagedPostListView.tsx
@@ -0,0 +1,43 @@
+import React from "react";
+
+import { HttpTimelinePostInfo } from "http/timeline";
+
+import useScrollToTop from "@/utilities/useScrollToTop";
+
+import TimelinePostListView from "./TimelinePostListView";
+
+export interface TimelinePagedPostListViewProps {
+ className?: string;
+ style?: React.CSSProperties;
+ posts: HttpTimelinePostInfo[];
+ onReload: () => void;
+}
+
+const TimelinePagedPostListView: React.FC<TimelinePagedPostListViewProps> = (
+ props
+) => {
+ const { className, style, posts, onReload } = props;
+
+ const [lastViewCount, setLastViewCount] = React.useState<number>(10);
+
+ const viewingPosts = React.useMemo(() => {
+ return lastViewCount >= posts.length
+ ? posts.slice()
+ : posts.slice(-lastViewCount);
+ }, [posts, lastViewCount]);
+
+ useScrollToTop(() => {
+ setLastViewCount(lastViewCount + 10);
+ }, lastViewCount < posts.length);
+
+ return (
+ <TimelinePostListView
+ className={className}
+ style={style}
+ posts={viewingPosts}
+ onReload={onReload}
+ />
+ );
+};
+
+export default TimelinePagedPostListView;
diff --git a/FrontEnd/src/views/timeline-common/TimelinePostContentView.tsx b/FrontEnd/src/views/timeline-common/TimelinePostContentView.tsx
new file mode 100644
index 00000000..f1b53335
--- /dev/null
+++ b/FrontEnd/src/views/timeline-common/TimelinePostContentView.tsx
@@ -0,0 +1,197 @@
+import React from "react";
+import classnames from "classnames";
+import { Remarkable } from "remarkable";
+
+import { UiLogicError } from "@/common";
+
+import { HttpNetworkError } from "http/common";
+import { getHttpTimelineClient, HttpTimelinePostInfo } from "http/timeline";
+
+import { useUser } from "@/services/user";
+
+import Skeleton from "../common/Skeleton";
+import LoadFailReload from "../common/LoadFailReload";
+
+const TextView: React.FC<TimelinePostContentViewProps> = (props) => {
+ const { post, className, style } = props;
+
+ const [text, setText] = React.useState<string | null>(null);
+ const [error, setError] = React.useState<"offline" | "error" | null>(null);
+
+ const [reloadKey, setReloadKey] = React.useState<number>(0);
+
+ React.useEffect(() => {
+ let subscribe = true;
+
+ setText(null);
+ setError(null);
+
+ void getHttpTimelineClient()
+ .getPostDataAsString(post.timelineName, post.id)
+ .then(
+ (data) => {
+ if (subscribe) setText(data);
+ },
+ (error) => {
+ if (subscribe) {
+ if (error instanceof HttpNetworkError) {
+ setError("offline");
+ } else {
+ setError("error");
+ }
+ }
+ }
+ );
+
+ return () => {
+ subscribe = false;
+ };
+ }, [post.timelineName, post.id, reloadKey]);
+
+ if (error != null) {
+ return (
+ <LoadFailReload
+ className={className}
+ style={style}
+ onReload={() => setReloadKey(reloadKey + 1)}
+ />
+ );
+ } else if (text == null) {
+ return <Skeleton />;
+ } else {
+ return (
+ <div className={className} style={style}>
+ {text}
+ </div>
+ );
+ }
+};
+
+const ImageView: React.FC<TimelinePostContentViewProps> = (props) => {
+ const { post, className, style } = props;
+
+ useUser();
+
+ return (
+ <img
+ src={getHttpTimelineClient().generatePostDataUrl(
+ post.timelineName,
+ post.id
+ )}
+ className={classnames(className, "timeline-content-image")}
+ style={style}
+ />
+ );
+};
+
+const MarkdownView: React.FC<TimelinePostContentViewProps> = (props) => {
+ const { post, className, style } = props;
+
+ const _remarkable = React.useRef<Remarkable>();
+
+ const getRemarkable = (): Remarkable => {
+ if (_remarkable.current) {
+ return _remarkable.current;
+ } else {
+ _remarkable.current = new Remarkable();
+ return _remarkable.current;
+ }
+ };
+
+ const [markdown, setMarkdown] = React.useState<string | null>(null);
+ const [error, setError] = React.useState<"offline" | "error" | null>(null);
+
+ const [reloadKey, setReloadKey] = React.useState<number>(0);
+
+ React.useEffect(() => {
+ let subscribe = true;
+
+ setMarkdown(null);
+ setError(null);
+
+ void getHttpTimelineClient()
+ .getPostDataAsString(post.timelineName, post.id)
+ .then(
+ (data) => {
+ if (subscribe) setMarkdown(data);
+ },
+ (error) => {
+ if (subscribe) {
+ if (error instanceof HttpNetworkError) {
+ setError("offline");
+ } else {
+ setError("error");
+ }
+ }
+ }
+ );
+
+ return () => {
+ subscribe = false;
+ };
+ }, [post.timelineName, post.id, reloadKey]);
+
+ const markdownHtml = React.useMemo<string | null>(() => {
+ if (markdown == null) return null;
+ return getRemarkable().render(markdown);
+ }, [markdown]);
+
+ if (error != null) {
+ return (
+ <LoadFailReload
+ className={className}
+ style={style}
+ onReload={() => setReloadKey(reloadKey + 1)}
+ />
+ );
+ } else if (markdown == null) {
+ return <Skeleton />;
+ } else {
+ if (markdownHtml == null) {
+ throw new UiLogicError("Markdown is not null but markdown html is.");
+ }
+ return (
+ <div
+ className={classnames(className, "markdown-container")}
+ style={style}
+ dangerouslySetInnerHTML={{
+ __html: markdownHtml,
+ }}
+ />
+ );
+ }
+};
+
+export interface TimelinePostContentViewProps {
+ post: HttpTimelinePostInfo;
+ className?: string;
+ style?: React.CSSProperties;
+}
+
+const viewMap: Record<string, React.FC<TimelinePostContentViewProps>> = {
+ "text/plain": TextView,
+ "text/markdown": MarkdownView,
+ "image/png": ImageView,
+ "image/jpeg": ImageView,
+ "image/gif": ImageView,
+ "image/webp": ImageView,
+};
+
+const TimelinePostContentView: React.FC<TimelinePostContentViewProps> = (
+ props
+) => {
+ const { post, className, style } = props;
+
+ const type = post.dataList[0].kind;
+
+ if (type in viewMap) {
+ const View = viewMap[type];
+ return <View post={post} className={className} style={style} />;
+ } else {
+ // TODO: i18n
+ console.error("Unknown post type", post);
+ return <div>Error, unknown post type!</div>;
+ }
+};
+
+export default TimelinePostContentView;
diff --git a/FrontEnd/src/views/timeline-common/TimelinePostDeleteConfirmDialog.tsx b/FrontEnd/src/views/timeline-common/TimelinePostDeleteConfirmDialog.tsx
new file mode 100644
index 00000000..b2c7a470
--- /dev/null
+++ b/FrontEnd/src/views/timeline-common/TimelinePostDeleteConfirmDialog.tsx
@@ -0,0 +1,37 @@
+import React from "react";
+import { Modal, Button } from "react-bootstrap";
+import { useTranslation } from "react-i18next";
+
+const TimelinePostDeleteConfirmDialog: React.FC<{
+ onClose: () => void;
+ onConfirm: () => void;
+}> = ({ onClose, onConfirm }) => {
+ const { t } = useTranslation();
+
+ return (
+ <Modal onHide={onClose} show centered>
+ <Modal.Header>
+ <Modal.Title className="text-danger">
+ {t("timeline.post.deleteDialog.title")}
+ </Modal.Title>
+ </Modal.Header>
+ <Modal.Body>{t("timeline.post.deleteDialog.prompt")}</Modal.Body>
+ <Modal.Footer>
+ <Button variant="secondary" onClick={onClose}>
+ {t("operationDialog.cancel")}
+ </Button>
+ <Button
+ variant="danger"
+ onClick={() => {
+ onConfirm();
+ onClose();
+ }}
+ >
+ {t("operationDialog.confirm")}
+ </Button>
+ </Modal.Footer>
+ </Modal>
+ );
+};
+
+export default TimelinePostDeleteConfirmDialog;
diff --git a/FrontEnd/src/views/timeline-common/TimelinePostEdit.tsx b/FrontEnd/src/views/timeline-common/TimelinePostEdit.tsx
new file mode 100644
index 00000000..0f470fd6
--- /dev/null
+++ b/FrontEnd/src/views/timeline-common/TimelinePostEdit.tsx
@@ -0,0 +1,291 @@
+import React from "react";
+import classnames from "classnames";
+import { useTranslation } from "react-i18next";
+import { Row, Col, Form } from "react-bootstrap";
+
+import { UiLogicError } from "@/common";
+
+import {
+ getHttpTimelineClient,
+ HttpTimelineInfo,
+ HttpTimelinePostInfo,
+ HttpTimelinePostPostRequestData,
+} from "http/timeline";
+
+import { pushAlert } from "@/services/alert";
+import { base64 } from "http/common";
+
+import BlobImage from "../common/BlobImage";
+import LoadingButton from "../common/LoadingButton";
+import { PopupMenu } from "../common/Menu";
+import MarkdownPostEdit from "./MarkdownPostEdit";
+
+interface TimelinePostEditTextProps {
+ text: string;
+ disabled: boolean;
+ onChange: (text: string) => void;
+ className?: string;
+ style?: React.CSSProperties;
+}
+
+const TimelinePostEditText: React.FC<TimelinePostEditTextProps> = (props) => {
+ const { text, disabled, onChange, className, style } = props;
+
+ return (
+ <Form.Control
+ as="textarea"
+ value={text}
+ disabled={disabled}
+ onChange={(event) => {
+ onChange(event.target.value);
+ }}
+ className={className}
+ style={style}
+ />
+ );
+};
+
+interface TimelinePostEditImageProps {
+ onSelect: (file: File | null) => void;
+ disabled: boolean;
+}
+
+const TimelinePostEditImage: React.FC<TimelinePostEditImageProps> = (props) => {
+ const { onSelect, disabled } = props;
+
+ const { t } = useTranslation();
+
+ const [file, setFile] = React.useState<File | null>(null);
+ const [error, setError] = React.useState<boolean>(false);
+
+ const onInputChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
+ setError(false);
+ const files = e.target.files;
+ if (files == null || files.length === 0) {
+ setFile(null);
+ onSelect(null);
+ } else {
+ setFile(files[0]);
+ }
+ };
+
+ React.useEffect(() => {
+ return () => {
+ onSelect(null);
+ };
+ }, [onSelect]);
+
+ return (
+ <>
+ <Form.Control
+ type="file"
+ onChange={onInputChange}
+ accept="image/*"
+ disabled={disabled}
+ className="mx-3 my-1"
+ />
+ {file != null && !error && (
+ <BlobImage
+ blob={file}
+ className="timeline-post-edit-image"
+ onLoad={() => onSelect(file)}
+ onError={() => {
+ onSelect(null);
+ setError(true);
+ }}
+ />
+ )}
+ {error ? <div className="text-danger">{t("loadImageError")}</div> : null}
+ </>
+ );
+};
+
+type PostKind = "text" | "markdown" | "image";
+
+const postKindIconClassNameMap: Record<PostKind, string> = {
+ text: "bi-fonts",
+ markdown: "bi-markdown",
+ image: "bi-image",
+};
+
+export interface TimelinePostEditProps {
+ className?: string;
+ timeline: HttpTimelineInfo;
+ onPosted: (newPost: HttpTimelinePostInfo) => void;
+ onHeightChange?: (height: number) => void;
+}
+
+const TimelinePostEdit: React.FC<TimelinePostEditProps> = (props) => {
+ const { timeline, onHeightChange, className, onPosted } = props;
+
+ const { t } = useTranslation();
+
+ const [process, setProcess] = React.useState<boolean>(false);
+
+ const [kind, setKind] = React.useState<Exclude<PostKind, "markdown">>("text");
+ const [showMarkdown, setShowMarkdown] = React.useState<boolean>(false);
+
+ const [text, setText] = React.useState<string>("");
+ const [image, setImage] = React.useState<File | null>(null);
+
+ const draftTextLocalStorageKey = `timeline.${timeline.name}.postDraft.text`;
+
+ React.useEffect(() => {
+ setText(window.localStorage.getItem(draftTextLocalStorageKey) ?? "");
+ }, [draftTextLocalStorageKey]);
+
+ const canSend =
+ (kind === "text" && text.length !== 0) ||
+ (kind === "image" && image != null);
+
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ const containerRef = React.useRef<HTMLDivElement>(null!);
+
+ const notifyHeightChange = (): void => {
+ if (onHeightChange) {
+ onHeightChange(containerRef.current.clientHeight);
+ }
+ };
+
+ React.useEffect(() => {
+ notifyHeightChange();
+ return () => {
+ if (onHeightChange) {
+ onHeightChange(0);
+ }
+ };
+ });
+
+ const onPostError = (): void => {
+ pushAlert({
+ type: "danger",
+ message: "timeline.sendPostFailed",
+ });
+ };
+
+ const onSend = async (): Promise<void> => {
+ setProcess(true);
+
+ let requestData: HttpTimelinePostPostRequestData;
+ switch (kind) {
+ case "text":
+ requestData = {
+ contentType: "text/plain",
+ data: await base64(text),
+ };
+ break;
+ case "image":
+ if (image == null) {
+ throw new UiLogicError(
+ "Content type is image but image blob is null."
+ );
+ }
+ requestData = {
+ contentType: image.type,
+ data: await base64(image),
+ };
+ break;
+ default:
+ throw new UiLogicError("Unknown content type.");
+ }
+
+ getHttpTimelineClient()
+ .postPost(timeline.name, {
+ dataList: [requestData],
+ })
+ .then(
+ (data) => {
+ if (kind === "text") {
+ setText("");
+ window.localStorage.removeItem(draftTextLocalStorageKey);
+ }
+ setProcess(false);
+ setKind("text");
+ onPosted(data);
+ },
+ (_) => {
+ setProcess(false);
+ onPostError();
+ }
+ );
+ };
+
+ return (
+ <div
+ ref={containerRef}
+ className={classnames("container-fluid bg-light", className)}
+ >
+ {showMarkdown ? (
+ <MarkdownPostEdit
+ className="w-100"
+ onClose={() => setShowMarkdown(false)}
+ timeline={timeline.name}
+ onPosted={onPosted}
+ onPostError={onPostError}
+ />
+ ) : (
+ <Row>
+ <Col className="px-1 py-1">
+ {(() => {
+ if (kind === "text") {
+ return (
+ <TimelinePostEditText
+ className="w-100 h-100 timeline-post-edit"
+ text={text}
+ disabled={process}
+ onChange={(t) => {
+ setText(t);
+ window.localStorage.setItem(draftTextLocalStorageKey, t);
+ }}
+ />
+ );
+ } else if (kind === "image") {
+ return (
+ <TimelinePostEditImage
+ onSelect={setImage}
+ disabled={process}
+ />
+ );
+ }
+ })()}
+ </Col>
+ <Col xs="auto" className="align-self-end m-1">
+ <div className="d-block text-center mt-1 mb-2">
+ <PopupMenu
+ items={(["text", "image", "markdown"] as const).map((kind) => ({
+ type: "button",
+ text: `timeline.post.type.${kind}`,
+ iconClassName: postKindIconClassNameMap[kind],
+ onClick: () => {
+ if (kind === "markdown") {
+ setShowMarkdown(true);
+ } else {
+ setKind(kind);
+ }
+ },
+ }))}
+ >
+ <i
+ className={classnames(
+ postKindIconClassNameMap[kind],
+ "icon-button large"
+ )}
+ />
+ </PopupMenu>
+ </div>
+ <LoadingButton
+ variant="primary"
+ onClick={onSend}
+ disabled={!canSend}
+ loading={process}
+ >
+ {t("timeline.send")}
+ </LoadingButton>
+ </Col>
+ </Row>
+ )}
+ </div>
+ );
+};
+
+export default TimelinePostEdit;
diff --git a/FrontEnd/src/views/timeline-common/TimelinePostListView.tsx b/FrontEnd/src/views/timeline-common/TimelinePostListView.tsx
new file mode 100644
index 00000000..49284720
--- /dev/null
+++ b/FrontEnd/src/views/timeline-common/TimelinePostListView.tsx
@@ -0,0 +1,79 @@
+import React, { Fragment } from "react";
+import classnames from "classnames";
+
+import { HttpTimelinePostInfo } from "http/timeline";
+
+import TimelinePostView from "./TimelinePostView";
+import TimelineDateLabel from "./TimelineDateLabel";
+
+function dateEqual(left: Date, right: Date): boolean {
+ return (
+ left.getDate() == right.getDate() &&
+ left.getMonth() == right.getMonth() &&
+ left.getFullYear() == right.getFullYear()
+ );
+}
+
+export interface TimelinePostListViewProps {
+ className?: string;
+ style?: React.CSSProperties;
+ posts: HttpTimelinePostInfo[];
+ onReload: () => void;
+}
+
+const TimelinePostListView: React.FC<TimelinePostListViewProps> = (props) => {
+ const { className, style, posts, onReload } = props;
+
+ const groupedPosts = React.useMemo<
+ {
+ date: Date;
+ posts: (HttpTimelinePostInfo & { index: number })[];
+ }[]
+ >(() => {
+ const result: {
+ date: Date;
+ posts: (HttpTimelinePostInfo & { index: number })[];
+ }[] = [];
+ let index = 0;
+ for (const post of posts) {
+ const time = new Date(post.time);
+ if (result.length === 0) {
+ result.push({ date: time, posts: [{ ...post, index }] });
+ } else {
+ const lastGroup = result[result.length - 1];
+ if (dateEqual(lastGroup.date, time)) {
+ lastGroup.posts.push({ ...post, index });
+ } else {
+ result.push({ date: time, posts: [{ ...post, index }] });
+ }
+ }
+ index++;
+ }
+ return result;
+ }, [posts]);
+
+ return (
+ <div style={style} className={classnames("timeline", className)}>
+ {groupedPosts.map((group) => {
+ return (
+ <Fragment key={group.date.toDateString()}>
+ <TimelineDateLabel date={group.date} />
+ {group.posts.map((post) => {
+ return (
+ <TimelinePostView
+ key={post.id}
+ post={post}
+ current={posts.length - 1 === post.index}
+ onChanged={onReload}
+ onDeleted={onReload}
+ />
+ );
+ })}
+ </Fragment>
+ );
+ })}
+ </div>
+ );
+};
+
+export default TimelinePostListView;
diff --git a/FrontEnd/src/views/timeline-common/TimelinePostView.tsx b/FrontEnd/src/views/timeline-common/TimelinePostView.tsx
new file mode 100644
index 00000000..e8b32c71
--- /dev/null
+++ b/FrontEnd/src/views/timeline-common/TimelinePostView.tsx
@@ -0,0 +1,151 @@
+import React from "react";
+import classnames from "classnames";
+import { Link } from "react-router-dom";
+import { useTranslation } from "react-i18next";
+
+import { getHttpTimelineClient, HttpTimelinePostInfo } from "http/timeline";
+
+import { pushAlert } from "@/services/alert";
+
+import UserAvatar from "../common/user/UserAvatar";
+import TimelineLine from "./TimelineLine";
+import TimelinePostContentView from "./TimelinePostContentView";
+import TimelinePostDeleteConfirmDialog from "./TimelinePostDeleteConfirmDialog";
+import PostPropertyChangeDialog from "./PostPropertyChangeDialog";
+
+export interface TimelinePostViewProps {
+ post: HttpTimelinePostInfo;
+ current?: boolean;
+ className?: string;
+ style?: React.CSSProperties;
+ cardStyle?: React.CSSProperties;
+ onChanged: (post: HttpTimelinePostInfo) => void;
+ onDeleted: () => void;
+}
+
+const TimelinePostView: React.FC<TimelinePostViewProps> = (props) => {
+ const { post, className, style, cardStyle, onChanged, onDeleted } = props;
+ const current = props.current === true;
+
+ const { t } = useTranslation();
+
+ const [operationMaskVisible, setOperationMaskVisible] =
+ React.useState<boolean>(false);
+ const [dialog, setDialog] = React.useState<
+ "delete" | "changeproperty" | null
+ >(null);
+
+ const cardRef = React.useRef<HTMLDivElement>(null);
+ React.useEffect(() => {
+ const cardIntersectionObserver = new IntersectionObserver(([e]) => {
+ if (e.intersectionRatio > 0) {
+ if (cardRef.current != null) {
+ cardRef.current.style.animationName = "timeline-post-enter";
+ }
+ }
+ });
+ if (cardRef.current) {
+ cardIntersectionObserver.observe(cardRef.current);
+ }
+
+ return () => {
+ cardIntersectionObserver.disconnect();
+ };
+ }, []);
+
+ return (
+ <div
+ id={`timeline-post-${post.id}`}
+ className={classnames("timeline-item", current && "current", className)}
+ style={style}
+ >
+ <TimelineLine center="node" current={current} />
+ <div ref={cardRef} className="timeline-item-card" style={cardStyle}>
+ {post.editable ? (
+ <i
+ className="bi-chevron-down text-info icon-button float-end"
+ onClick={(e) => {
+ setOperationMaskVisible(true);
+ e.stopPropagation();
+ }}
+ />
+ ) : null}
+ <div className="timeline-item-header">
+ <span className="me-2">
+ <span>
+ <Link to={"/users/" + props.post.author.username}>
+ <UserAvatar
+ username={post.author.username}
+ className="timeline-avatar me-1"
+ />
+ </Link>
+ <small className="text-dark me-2">{post.author.nickname}</small>
+ <small className="text-secondary white-space-no-wrap">
+ {new Date(post.time).toLocaleTimeString()}
+ </small>
+ </span>
+ </span>
+ </div>
+ <div className="timeline-content">
+ <TimelinePostContentView post={post} />
+ </div>
+ {operationMaskVisible ? (
+ <div
+ className="position-absolute position-lt w-100 h-100 mask d-flex justify-content-around align-items-center"
+ onClick={() => {
+ setOperationMaskVisible(false);
+ }}
+ >
+ <span
+ className="tl-color-primary"
+ onClick={(e) => {
+ setDialog("changeproperty");
+ e.stopPropagation();
+ }}
+ >
+ {t("changeProperty")}
+ </span>
+ <span
+ className="tl-color-danger"
+ onClick={(e) => {
+ setDialog("delete");
+ e.stopPropagation();
+ }}
+ >
+ {t("delete")}
+ </span>
+ </div>
+ ) : null}
+ </div>
+ {dialog === "delete" ? (
+ <TimelinePostDeleteConfirmDialog
+ onClose={() => {
+ setDialog(null);
+ setOperationMaskVisible(false);
+ }}
+ onConfirm={() => {
+ void getHttpTimelineClient()
+ .deletePost(post.timelineName, post.id)
+ .then(onDeleted, () => {
+ pushAlert({
+ type: "danger",
+ message: "timeline.deletePostFailed",
+ });
+ });
+ }}
+ />
+ ) : dialog === "changeproperty" ? (
+ <PostPropertyChangeDialog
+ onClose={() => {
+ setDialog(null);
+ setOperationMaskVisible(false);
+ }}
+ post={post}
+ onSuccess={onChanged}
+ />
+ ) : null}
+ </div>
+ );
+};
+
+export default TimelinePostView;
diff --git a/FrontEnd/src/views/timeline-common/TimelinePropertyChangeDialog.tsx b/FrontEnd/src/views/timeline-common/TimelinePropertyChangeDialog.tsx
new file mode 100644
index 00000000..83b24d01
--- /dev/null
+++ b/FrontEnd/src/views/timeline-common/TimelinePropertyChangeDialog.tsx
@@ -0,0 +1,87 @@
+import React from "react";
+
+import {
+ getHttpTimelineClient,
+ HttpTimelineInfo,
+ HttpTimelinePatchRequest,
+ kTimelineVisibilities,
+ TimelineVisibility,
+} from "http/timeline";
+
+import OperationDialog from "../common/OperationDialog";
+
+export interface TimelinePropertyChangeDialogProps {
+ open: boolean;
+ close: () => void;
+ timeline: HttpTimelineInfo;
+ onChange: () => void;
+}
+
+const labelMap: { [key in TimelineVisibility]: string } = {
+ Private: "timeline.visibility.private",
+ Public: "timeline.visibility.public",
+ Register: "timeline.visibility.register",
+};
+
+const TimelinePropertyChangeDialog: React.FC<TimelinePropertyChangeDialogProps> =
+ (props) => {
+ const { timeline, onChange } = props;
+
+ return (
+ <OperationDialog
+ title={"timeline.dialogChangeProperty.title"}
+ inputScheme={
+ [
+ {
+ type: "text",
+ label: "timeline.dialogChangeProperty.titleField",
+ initValue: timeline.title,
+ },
+ {
+ type: "select",
+ label: "timeline.dialogChangeProperty.visibility",
+ options: kTimelineVisibilities.map((v) => ({
+ label: labelMap[v],
+ value: v,
+ })),
+ initValue: timeline.visibility,
+ },
+ {
+ type: "text",
+ label: "timeline.dialogChangeProperty.description",
+ initValue: timeline.description,
+ },
+ {
+ type: "color",
+ label: "timeline.dialogChangeProperty.color",
+ initValue: timeline.color ?? null,
+ canBeNull: true,
+ },
+ ] as const
+ }
+ open={props.open}
+ close={props.close}
+ onProcess={([newTitle, newVisibility, newDescription, newColor]) => {
+ const req: HttpTimelinePatchRequest = {};
+ if (newTitle !== timeline.title) {
+ req.title = newTitle;
+ }
+ if (newVisibility !== timeline.visibility) {
+ req.visibility = newVisibility as TimelineVisibility;
+ }
+ if (newDescription !== timeline.description) {
+ req.description = newDescription;
+ }
+ const nc = newColor ?? "";
+ if (nc !== timeline.color) {
+ req.color = nc;
+ }
+ return getHttpTimelineClient()
+ .patchTimeline(timeline.name, req)
+ .then(onChange);
+ }}
+ />
+ );
+ };
+
+export default TimelinePropertyChangeDialog;
diff --git a/FrontEnd/src/views/timeline-common/TimelineTop.tsx b/FrontEnd/src/views/timeline-common/TimelineTop.tsx
new file mode 100644
index 00000000..dabbdf1e
--- /dev/null
+++ b/FrontEnd/src/views/timeline-common/TimelineTop.tsx
@@ -0,0 +1,27 @@
+import React from "react";
+import classnames from "classnames";
+
+import TimelineLine, { TimelineLineProps } from "./TimelineLine";
+
+export interface TimelineTopProps {
+ height?: number | string;
+ lineProps?: TimelineLineProps;
+ className?: string;
+ style?: React.CSSProperties;
+}
+
+const TimelineTop: React.FC<TimelineTopProps> = (props) => {
+ const { height, style, className } = props;
+ const lineProps = props.lineProps ?? { center: "none" };
+
+ return (
+ <div
+ style={{ ...style, height: height }}
+ className={classnames("timeline-top", className)}
+ >
+ <TimelineLine {...lineProps} />
+ </div>
+ );
+};
+
+export default TimelineTop;
diff --git a/FrontEnd/src/views/timeline-common/timeline-common.sass b/FrontEnd/src/views/timeline-common/timeline-common.sass
new file mode 100644
index 00000000..4400fead
--- /dev/null
+++ b/FrontEnd/src/views/timeline-common/timeline-common.sass
@@ -0,0 +1,259 @@
+@use 'sass:color'
+
+.timeline
+ z-index: 0
+ position: relative
+ width: 100%
+ overflow-wrap: break-word
+ animation: 1s timeline-enter
+
+$timeline-line-width: 7px
+$timeline-line-node-radius: 18px
+$timeline-line-color: var(--tl-primary-color)
+$timeline-line-color-current: var(--tl-primary-enhance-color)
+
+@keyframes timeline-line-node-noncurrent
+ to
+ box-shadow: 0 0 20px 3px var(--tl-primary-lighter-color)
+
+@keyframes timeline-line-node-current
+ to
+ box-shadow: 0 0 20px 3px var(--tl-primary-enhance-lighter-color)
+
+@keyframes timeline-line-node-loading
+ to
+ box-shadow: 0 0 20px 3px var(--tl-primary-lighter-color)
+
+@keyframes timeline-line-node-loading-edge
+ from
+ transform: rotate(0turn)
+ to
+ transform: rotate(1turn)
+
+@keyframes timeline-enter
+ from
+ transform: translate(0, -100vh)
+
+@keyframes timeline-top-loading-enter
+ from
+ transform: translate(0, -100%)
+
+@keyframes timeline-post-enter
+ from
+ transform: translate(0, -100%)
+ opacity: 0
+
+ to
+ opacity: 1
+
+.timeline-top-loading-enter
+ animation: 1s timeline-top-loading-enter
+
+.timeline-line
+ display: flex
+ flex-direction: column
+ align-items: center
+ width: 30px
+
+ position: absolute
+ z-index: 1
+ left: 2em
+ top: 0
+ bottom: 0
+
+ transition: left 0.5s
+
+ @include media-breakpoint-down(sm)
+ left: 1em
+
+ .segment
+ width: $timeline-line-width
+ background: $timeline-line-color
+
+ &.start
+ height: 1.8em
+ flex: 0 0 auto
+
+ &.end
+ flex: 1 1 auto
+
+ &.current-end
+ height: 2em
+ flex: 0 0 auto
+ background: linear-gradient($timeline-line-color-current, white)
+
+ .node-container
+ flex: 0 0 auto
+ position: relative
+ width: $timeline-line-node-radius
+ height: $timeline-line-node-radius
+
+ .node
+ width: $timeline-line-node-radius + 2
+ height: $timeline-line-node-radius + 2
+ position: absolute
+ background: $timeline-line-color
+ left: -1px
+ top: -1px
+ border-radius: 50%
+ box-sizing: border-box
+ z-index: 1
+ animation: 1s infinite alternate
+ animation-name: timeline-line-node-noncurrent
+
+ .node-loading-edge
+ color: $timeline-line-color
+ width: $timeline-line-node-radius + 20
+ height: $timeline-line-node-radius + 20
+ position: absolute
+ left: -10px
+ top: -10px
+ box-sizing: border-box
+ z-index: 2
+ animation: 1.5s linear infinite timeline-line-node-loading-edge
+
+ &.current
+ .segment
+ &.start
+ background: linear-gradient($timeline-line-color, $timeline-line-color-current)
+ &.end
+ background: $timeline-line-color-current
+ .node
+ background: $timeline-line-color-current
+ animation-name: timeline-line-node-current
+
+ &.loading
+ .node
+ background: $timeline-line-color
+ animation-name: timeline-line-node-loading
+
+.timeline-item.current
+ padding-bottom: 2.5em
+
+.timeline-top
+ position: relative
+ text-align: right
+
+.timeline-item
+ position: relative
+ padding: 0.5em
+
+.timeline-item-card
+ @extend .cru-card
+ position: relative
+ padding: 0.3em 0.5em 1em 4em
+ transition: background 0.5s, padding-left 0.5s
+ animation: 0.6s forwards
+ opacity: 0
+
+ @include media-breakpoint-down(sm)
+ padding-left: 3em
+
+.timeline-item-header
+ display: flex
+ align-items: center
+ @extend .my-2
+
+.timeline-avatar
+ border-radius: 50%
+ width: 2em
+ height: 2em
+
+.timeline-item-delete-button
+ position: absolute
+ right: 0
+ bottom: 0
+
+.timeline-content
+ white-space: pre-line
+
+.timeline-content-image
+ max-width: 80%
+ max-height: 200px
+
+.timeline-date-item
+ position: relative
+ padding: 0.3em 0 0.3em 4em
+
+.timeline-date-item-badge
+ display: inline-block
+ padding: 0.1em 0.4em
+ border-radius: 0.4em
+ background: #7c7c7c
+ color: white
+ font-size: 0.8em
+
+.timeline-post-edit-image
+ max-width: 100px
+ max-height: 100px
+
+.mask
+ background: change-color($color: white, $alpha: 0.8)
+ z-index: 100
+
+.timeline-sync-state-badge
+ font-size: 0.8em
+ padding: 3px 8px
+ border-radius: 5px
+ background: #e8fbff
+
+.timeline-sync-state-badge-pin
+ display: inline-block
+ width: 0.4em
+ height: 0.4em
+ border-radius: 50%
+ vertical-align: middle
+ margin-right: 0.6em
+
+.timeline-template-card
+ position: fixed
+ top: 56px
+ right: 0
+ margin: 0.5em
+
+.timeline-markdown-post-edit-page
+ overflow: scroll
+ max-height: 300px
+
+.timeline-markdown-post-edit-image-container
+ position: relative
+ text-align: center
+ margin-bottom: 1em
+
+.timeline-markdown-post-edit-image
+ max-width: 100%
+ max-height: 200px
+
+.timeline-markdown-post-edit-image-delete-button
+ position: absolute
+ right: 10px
+ top: 2px
+
+.connection-status-badge
+ font-size: 0.8em
+ border-radius: 5px
+ padding: 0.1em 1em
+ background-color: rgb(234 242 255)
+
+ &::before
+ width: 10px
+ height: 10px
+ border-radius: 50%
+ display: inline-block
+ content: ''
+ margin-right: 0.6em
+
+ &.success
+ color: #006100
+ &::before
+ background-color: #006100
+
+ &.warning
+ color: #e4a700
+ &::before
+ background-color: #e4a700
+
+ &.danger
+ color: #fd1616
+ &::before
+ background-color: #fd1616
diff --git a/FrontEnd/src/views/timeline/TimelineCard.tsx b/FrontEnd/src/views/timeline/TimelineCard.tsx
new file mode 100644
index 00000000..e031b565
--- /dev/null
+++ b/FrontEnd/src/views/timeline/TimelineCard.tsx
@@ -0,0 +1,74 @@
+import React from "react";
+
+import { TimelinePageCardProps } from "../timeline-common/TimelinePageTemplate";
+import TimelinePageCardTemplate from "../timeline-common/TimelinePageCardTemplate";
+
+import UserAvatar from "../common/user/UserAvatar";
+import TimelineDeleteDialog from "./TimelineDeleteDialog";
+
+const TimelineCard: React.FC<TimelinePageCardProps> = (props) => {
+ const { timeline } = props;
+
+ const [dialog, setDialog] = React.useState<
+ "member" | "property" | "delete" | null
+ >(null);
+
+ return (
+ <>
+ <TimelinePageCardTemplate
+ infoArea={
+ <>
+ <h3 className="tl-color-primary d-inline-block align-middle">
+ {timeline.title}
+ <small className="ms-3 text-secondary">{timeline.name}</small>
+ </h3>
+ <div className="align-middle">
+ <UserAvatar
+ username={timeline.owner.username}
+ className="avatar small rounded-circle me-3"
+ />
+ {timeline.owner.nickname}
+ <small className="ms-3 text-secondary">
+ @{timeline.owner.username}
+ </small>
+ </div>
+ </>
+ }
+ manageItems={
+ timeline.manageable
+ ? [
+ {
+ type: "button",
+ text: "timeline.manageItem.property",
+ onClick: () => setDialog("property"),
+ },
+ { type: "divider" },
+ {
+ type: "button",
+ onClick: () => setDialog("delete"),
+ color: "danger",
+ text: "timeline.manageItem.delete",
+ },
+ ]
+ : undefined
+ }
+ dialog={dialog}
+ setDialog={setDialog}
+ {...props}
+ />
+ {(() => {
+ if (dialog === "delete") {
+ return (
+ <TimelineDeleteDialog
+ timeline={timeline}
+ open
+ close={() => setDialog(null)}
+ />
+ );
+ }
+ })()}
+ </>
+ );
+};
+
+export default TimelineCard;
diff --git a/FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx b/FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx
new file mode 100644
index 00000000..8821507d
--- /dev/null
+++ b/FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx
@@ -0,0 +1,55 @@
+import React from "react";
+import { useHistory } from "react-router";
+import { Trans } from "react-i18next";
+
+import { getHttpTimelineClient, HttpTimelineInfo } from "http/timeline";
+
+import OperationDialog from "../common/OperationDialog";
+
+interface TimelineDeleteDialog {
+ timeline: HttpTimelineInfo;
+ open: boolean;
+ close: () => void;
+}
+
+const TimelineDeleteDialog: React.FC<TimelineDeleteDialog> = (props) => {
+ const history = useHistory();
+
+ const { timeline } = props;
+
+ return (
+ <OperationDialog
+ open={props.open}
+ close={props.close}
+ title="timeline.deleteDialog.title"
+ themeColor="danger"
+ inputPrompt={() => {
+ return (
+ <Trans i18nKey="timeline.deleteDialog.inputPrompt">
+ 0<code className="mx-2">{{ name }}</code>2
+ </Trans>
+ );
+ }}
+ inputScheme={[
+ {
+ type: "text",
+ },
+ ]}
+ inputValidator={([value]) => {
+ if (value !== timeline.name) {
+ return { 0: "timeline.deleteDialog.notMatch" };
+ } else {
+ return null;
+ }
+ }}
+ onProcess={() => {
+ return getHttpTimelineClient().deleteTimeline(timeline.name);
+ }}
+ onSuccessAndClose={() => {
+ history.replace("/");
+ }}
+ />
+ );
+};
+
+export default TimelineDeleteDialog;
diff --git a/FrontEnd/src/views/timeline/index.tsx b/FrontEnd/src/views/timeline/index.tsx
new file mode 100644
index 00000000..c5bfd7ab
--- /dev/null
+++ b/FrontEnd/src/views/timeline/index.tsx
@@ -0,0 +1,23 @@
+import React from "react";
+import { useParams } from "react-router";
+
+import TimelinePageTemplate from "../timeline-common/TimelinePageTemplate";
+import TimelineCard from "./TimelineCard";
+
+const TimelinePage: React.FC = () => {
+ const { name } = useParams<{ name: string }>();
+
+ const [reloadKey, setReloadKey] = React.useState<number>(0);
+
+ return (
+ <TimelinePageTemplate
+ timelineName={name}
+ notFoundI18nKey="timeline.timelineNotExist"
+ reloadKey={reloadKey}
+ CardComponent={TimelineCard}
+ onReload={() => setReloadKey(reloadKey + 1)}
+ />
+ );
+};
+
+export default TimelinePage;
diff --git a/FrontEnd/src/views/timeline/timeline.sass b/FrontEnd/src/views/timeline/timeline.sass
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/FrontEnd/src/views/timeline/timeline.sass
diff --git a/FrontEnd/src/views/user/UserCard.tsx b/FrontEnd/src/views/user/UserCard.tsx
new file mode 100644
index 00000000..e7e4252e
--- /dev/null
+++ b/FrontEnd/src/views/user/UserCard.tsx
@@ -0,0 +1,51 @@
+import React from "react";
+
+import TimelinePageCardTemplate from "../timeline-common/TimelinePageCardTemplate";
+import { TimelinePageCardProps } from "../timeline-common/TimelinePageTemplate";
+import UserAvatar from "../common/user/UserAvatar";
+
+const UserCard: React.FC<TimelinePageCardProps> = (props) => {
+ const { timeline } = props;
+
+ const [dialog, setDialog] = React.useState<"member" | "property" | null>(
+ null
+ );
+
+ return (
+ <>
+ <TimelinePageCardTemplate
+ infoArea={
+ <>
+ <h3 className="tl-color-primary d-inline-block align-middle">
+ {timeline.title}
+ <small className="ms-3 text-secondary">{timeline.name}</small>
+ </h3>
+ <div className="align-middle">
+ <UserAvatar
+ username={timeline.owner.username}
+ className="avatar small rounded-circle me-3"
+ />
+ {timeline.owner.nickname}
+ </div>
+ </>
+ }
+ manageItems={
+ timeline.manageable
+ ? [
+ {
+ type: "button",
+ text: "timeline.manageItem.property",
+ onClick: () => setDialog("property"),
+ },
+ ]
+ : undefined
+ }
+ dialog={dialog}
+ setDialog={setDialog}
+ {...props}
+ />
+ </>
+ );
+};
+
+export default UserCard;
diff --git a/FrontEnd/src/views/user/index.tsx b/FrontEnd/src/views/user/index.tsx
new file mode 100644
index 00000000..57454d0d
--- /dev/null
+++ b/FrontEnd/src/views/user/index.tsx
@@ -0,0 +1,28 @@
+import React from "react";
+import { useParams } from "react-router";
+
+import TimelinePageTemplate from "../timeline-common/TimelinePageTemplate";
+import UserCard from "./UserCard";
+
+const UserPage: React.FC = () => {
+ const { username } = useParams<{ username: string }>();
+
+ const [reloadKey, setReloadKey] = React.useState<number>(0);
+
+ let dialogElement: React.ReactElement | undefined;
+
+ return (
+ <>
+ <TimelinePageTemplate
+ timelineName={`@${username}`}
+ notFoundI18nKey="timeline.userNotExist"
+ reloadKey={reloadKey}
+ onReload={() => setReloadKey(reloadKey + 1)}
+ CardComponent={UserCard}
+ />
+ {dialogElement}
+ </>
+ );
+};
+
+export default UserPage;
diff --git a/FrontEnd/src/views/user/user.sass b/FrontEnd/src/views/user/user.sass
new file mode 100644
index 00000000..63a28e05
--- /dev/null
+++ b/FrontEnd/src/views/user/user.sass
@@ -0,0 +1,7 @@
+.change-avatar-cropper-row
+ max-height: 400px
+
+.change-avatar-img
+ min-width: 50%
+ max-width: 100%
+ max-height: 400px