aboutsummaryrefslogtreecommitdiff
path: root/FrontEnd/src/app/views
diff options
context:
space:
mode:
authorcrupest <crupest@outlook.com>2021-06-15 14:14:28 +0800
committercrupest <crupest@outlook.com>2021-06-15 14:14:28 +0800
commit47587812b809fee2a95c76266d9d0e42fc4ac1ca (patch)
treebfaa7320c838e21edf88b5a037263f89a8012222 /FrontEnd/src/app/views
parentda26373c7fc13cded47428b27638b349b0432437 (diff)
downloadtimeline-47587812b809fee2a95c76266d9d0e42fc4ac1ca.tar.gz
timeline-47587812b809fee2a95c76266d9d0e42fc4ac1ca.tar.bz2
timeline-47587812b809fee2a95c76266d9d0e42fc4ac1ca.zip
...
Diffstat (limited to 'FrontEnd/src/app/views')
-rw-r--r--FrontEnd/src/app/views/about/about.sass4
-rw-r--r--FrontEnd/src/app/views/about/author-avatar.pngbin12038 -> 0 bytes
-rw-r--r--FrontEnd/src/app/views/about/github.pngbin4268 -> 0 bytes
-rw-r--r--FrontEnd/src/app/views/about/index.tsx156
-rw-r--r--FrontEnd/src/app/views/admin/Admin.tsx48
-rw-r--r--FrontEnd/src/app/views/admin/AdminNav.tsx44
-rw-r--r--FrontEnd/src/app/views/admin/MoreAdmin.tsx13
-rw-r--r--FrontEnd/src/app/views/admin/UserAdmin.tsx396
-rw-r--r--FrontEnd/src/app/views/admin/admin.sass22
-rw-r--r--FrontEnd/src/app/views/center/CenterBoards.tsx107
-rw-r--r--FrontEnd/src/app/views/center/TimelineBoard.tsx370
-rw-r--r--FrontEnd/src/app/views/center/TimelineCreateDialog.tsx53
-rw-r--r--FrontEnd/src/app/views/center/center.sass36
-rw-r--r--FrontEnd/src/app/views/center/index.tsx64
-rw-r--r--FrontEnd/src/app/views/common/AppBar.tsx80
-rw-r--r--FrontEnd/src/app/views/common/BlobImage.tsx27
-rw-r--r--FrontEnd/src/app/views/common/ConfirmDialog.tsx40
-rw-r--r--FrontEnd/src/app/views/common/FlatButton.tsx36
-rw-r--r--FrontEnd/src/app/views/common/FullPage.tsx39
-rw-r--r--FrontEnd/src/app/views/common/ImageCropper.tsx306
-rw-r--r--FrontEnd/src/app/views/common/LoadFailReload.tsx37
-rw-r--r--FrontEnd/src/app/views/common/LoadingButton.tsx29
-rw-r--r--FrontEnd/src/app/views/common/LoadingPage.tsx12
-rw-r--r--FrontEnd/src/app/views/common/Menu.tsx92
-rw-r--r--FrontEnd/src/app/views/common/OperationDialog.tsx471
-rw-r--r--FrontEnd/src/app/views/common/SearchInput.tsx78
-rw-r--r--FrontEnd/src/app/views/common/Skeleton.tsx30
-rw-r--r--FrontEnd/src/app/views/common/TabPages.tsx74
-rw-r--r--FrontEnd/src/app/views/common/TimelineLogo.tsx26
-rw-r--r--FrontEnd/src/app/views/common/ToggleIconButton.tsx30
-rw-r--r--FrontEnd/src/app/views/common/UserTimelineLogo.tsx26
-rw-r--r--FrontEnd/src/app/views/common/alert/AlertHost.tsx106
-rw-r--r--FrontEnd/src/app/views/common/alert/alert.sass15
-rw-r--r--FrontEnd/src/app/views/common/common.sass191
-rw-r--r--FrontEnd/src/app/views/common/user/UserAvatar.tsx19
-rw-r--r--FrontEnd/src/app/views/home/TimelineListView.tsx101
-rw-r--r--FrontEnd/src/app/views/home/WebsiteIntroduction.tsx77
-rw-r--r--FrontEnd/src/app/views/home/home.sass29
-rw-r--r--FrontEnd/src/app/views/home/index.tsx74
-rw-r--r--FrontEnd/src/app/views/login/index.tsx151
-rw-r--r--FrontEnd/src/app/views/login/login.sass2
-rw-r--r--FrontEnd/src/app/views/search/index.tsx128
-rw-r--r--FrontEnd/src/app/views/search/search.sass13
-rw-r--r--FrontEnd/src/app/views/settings/ChangeAvatarDialog.tsx305
-rw-r--r--FrontEnd/src/app/views/settings/ChangeNicknameDialog.tsx32
-rw-r--r--FrontEnd/src/app/views/settings/ChangePasswordDialog.tsx68
-rw-r--r--FrontEnd/src/app/views/settings/index.tsx138
-rw-r--r--FrontEnd/src/app/views/settings/settings.sass14
-rw-r--r--FrontEnd/src/app/views/timeline-common/CollapseButton.tsx23
-rw-r--r--FrontEnd/src/app/views/timeline-common/ConnectionStatusBadge.tsx39
-rw-r--r--FrontEnd/src/app/views/timeline-common/MarkdownPostEdit.tsx205
-rw-r--r--FrontEnd/src/app/views/timeline-common/PostPropertyChangeDialog.tsx36
-rw-r--r--FrontEnd/src/app/views/timeline-common/Timeline.tsx143
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelineDateLabel.tsx19
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelineLine.tsx51
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelineLoading.tsx18
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelineMember.tsx195
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelinePageCardTemplate.tsx158
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx190
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelinePagedPostListView.tsx43
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelinePostContentView.tsx197
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelinePostDeleteConfirmDialog.tsx37
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelinePostEdit.tsx291
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelinePostListView.tsx79
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelinePostView.tsx151
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx87
-rw-r--r--FrontEnd/src/app/views/timeline-common/TimelineTop.tsx27
-rw-r--r--FrontEnd/src/app/views/timeline-common/timeline-common.sass259
-rw-r--r--FrontEnd/src/app/views/timeline/TimelineCard.tsx74
-rw-r--r--FrontEnd/src/app/views/timeline/TimelineDeleteDialog.tsx55
-rw-r--r--FrontEnd/src/app/views/timeline/index.tsx23
-rw-r--r--FrontEnd/src/app/views/timeline/timeline.sass0
-rw-r--r--FrontEnd/src/app/views/user/UserCard.tsx51
-rw-r--r--FrontEnd/src/app/views/user/index.tsx28
-rw-r--r--FrontEnd/src/app/views/user/user.sass7
75 files changed, 0 insertions, 6695 deletions
diff --git a/FrontEnd/src/app/views/about/about.sass b/FrontEnd/src/app/views/about/about.sass
deleted file mode 100644
index f4d00cae..00000000
--- a/FrontEnd/src/app/views/about/about.sass
+++ /dev/null
@@ -1,4 +0,0 @@
-.about-link-icon
- @extend .mx-2
- width: 1.2em
- height: 1.2em
diff --git a/FrontEnd/src/app/views/about/author-avatar.png b/FrontEnd/src/app/views/about/author-avatar.png
deleted file mode 100644
index d890d8d0..00000000
--- a/FrontEnd/src/app/views/about/author-avatar.png
+++ /dev/null
Binary files differ
diff --git a/FrontEnd/src/app/views/about/github.png b/FrontEnd/src/app/views/about/github.png
deleted file mode 100644
index ea6ff545..00000000
--- a/FrontEnd/src/app/views/about/github.png
+++ /dev/null
Binary files differ
diff --git a/FrontEnd/src/app/views/about/index.tsx b/FrontEnd/src/app/views/about/index.tsx
deleted file mode 100644
index a8a53a97..00000000
--- a/FrontEnd/src/app/views/about/index.tsx
+++ /dev/null
@@ -1,156 +0,0 @@
-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/app/views/admin/Admin.tsx b/FrontEnd/src/app/views/admin/Admin.tsx
deleted file mode 100644
index 0b6d1f05..00000000
--- a/FrontEnd/src/app/views/admin/Admin.tsx
+++ /dev/null
@@ -1,48 +0,0 @@
-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/app/views/admin/AdminNav.tsx b/FrontEnd/src/app/views/admin/AdminNav.tsx
deleted file mode 100644
index 47e2138f..00000000
--- a/FrontEnd/src/app/views/admin/AdminNav.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-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/app/views/admin/MoreAdmin.tsx b/FrontEnd/src/app/views/admin/MoreAdmin.tsx
deleted file mode 100644
index 042789a0..00000000
--- a/FrontEnd/src/app/views/admin/MoreAdmin.tsx
+++ /dev/null
@@ -1,13 +0,0 @@
-import React from "react";
-
-import { AuthUser } from "@/services/user";
-
-export interface MoreAdminProps {
- user: AuthUser;
-}
-
-const MoreAdmin: React.FC<MoreAdminProps> = () => {
- return <>More...</>;
-};
-
-export default MoreAdmin;
diff --git a/FrontEnd/src/app/views/admin/UserAdmin.tsx b/FrontEnd/src/app/views/admin/UserAdmin.tsx
deleted file mode 100644
index 558d3aee..00000000
--- a/FrontEnd/src/app/views/admin/UserAdmin.tsx
+++ /dev/null
@@ -1,396 +0,0 @@
-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/app/views/admin/admin.sass b/FrontEnd/src/app/views/admin/admin.sass
deleted file mode 100644
index 1ce010f8..00000000
--- a/FrontEnd/src/app/views/admin/admin.sass
+++ /dev/null
@@ -1,22 +0,0 @@
-.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/app/views/center/CenterBoards.tsx b/FrontEnd/src/app/views/center/CenterBoards.tsx
deleted file mode 100644
index f5200415..00000000
--- a/FrontEnd/src/app/views/center/CenterBoards.tsx
+++ /dev/null
@@ -1,107 +0,0 @@
-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/app/views/center/TimelineBoard.tsx b/FrontEnd/src/app/views/center/TimelineBoard.tsx
deleted file mode 100644
index 35249f66..00000000
--- a/FrontEnd/src/app/views/center/TimelineBoard.tsx
+++ /dev/null
@@ -1,370 +0,0 @@
-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/app/views/center/TimelineCreateDialog.tsx b/FrontEnd/src/app/views/center/TimelineCreateDialog.tsx
deleted file mode 100644
index b4e25ba1..00000000
--- a/FrontEnd/src/app/views/center/TimelineCreateDialog.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-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/app/views/center/center.sass b/FrontEnd/src/app/views/center/center.sass
deleted file mode 100644
index c0dfb9c0..00000000
--- a/FrontEnd/src/app/views/center/center.sass
+++ /dev/null
@@ -1,36 +0,0 @@
-.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/app/views/center/index.tsx b/FrontEnd/src/app/views/center/index.tsx
deleted file mode 100644
index 0a2abb2c..00000000
--- a/FrontEnd/src/app/views/center/index.tsx
+++ /dev/null
@@ -1,64 +0,0 @@
-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/app/views/common/AppBar.tsx b/FrontEnd/src/app/views/common/AppBar.tsx
deleted file mode 100644
index 91dfbee9..00000000
--- a/FrontEnd/src/app/views/common/AppBar.tsx
+++ /dev/null
@@ -1,80 +0,0 @@
-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/app/views/common/BlobImage.tsx b/FrontEnd/src/app/views/common/BlobImage.tsx
deleted file mode 100644
index 0dd25c52..00000000
--- a/FrontEnd/src/app/views/common/BlobImage.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-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/app/views/common/ConfirmDialog.tsx b/FrontEnd/src/app/views/common/ConfirmDialog.tsx
deleted file mode 100644
index 72940c51..00000000
--- a/FrontEnd/src/app/views/common/ConfirmDialog.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-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/app/views/common/FlatButton.tsx b/FrontEnd/src/app/views/common/FlatButton.tsx
deleted file mode 100644
index b1f7a051..00000000
--- a/FrontEnd/src/app/views/common/FlatButton.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-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/app/views/common/FullPage.tsx b/FrontEnd/src/app/views/common/FullPage.tsx
deleted file mode 100644
index 1b59045a..00000000
--- a/FrontEnd/src/app/views/common/FullPage.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-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/app/views/common/ImageCropper.tsx b/FrontEnd/src/app/views/common/ImageCropper.tsx
deleted file mode 100644
index 2ef5b7ed..00000000
--- a/FrontEnd/src/app/views/common/ImageCropper.tsx
+++ /dev/null
@@ -1,306 +0,0 @@
-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/app/views/common/LoadFailReload.tsx b/FrontEnd/src/app/views/common/LoadFailReload.tsx
deleted file mode 100644
index a80e7b76..00000000
--- a/FrontEnd/src/app/views/common/LoadFailReload.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-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/app/views/common/LoadingButton.tsx b/FrontEnd/src/app/views/common/LoadingButton.tsx
deleted file mode 100644
index cd9f1adc..00000000
--- a/FrontEnd/src/app/views/common/LoadingButton.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-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/app/views/common/LoadingPage.tsx b/FrontEnd/src/app/views/common/LoadingPage.tsx
deleted file mode 100644
index 590fafa0..00000000
--- a/FrontEnd/src/app/views/common/LoadingPage.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-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/app/views/common/Menu.tsx b/FrontEnd/src/app/views/common/Menu.tsx
deleted file mode 100644
index ae73a331..00000000
--- a/FrontEnd/src/app/views/common/Menu.tsx
+++ /dev/null
@@ -1,92 +0,0 @@
-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/app/views/common/OperationDialog.tsx b/FrontEnd/src/app/views/common/OperationDialog.tsx
deleted file mode 100644
index ac4c51b9..00000000
--- a/FrontEnd/src/app/views/common/OperationDialog.tsx
+++ /dev/null
@@ -1,471 +0,0 @@
-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/app/views/common/SearchInput.tsx b/FrontEnd/src/app/views/common/SearchInput.tsx
deleted file mode 100644
index ccb6dad6..00000000
--- a/FrontEnd/src/app/views/common/SearchInput.tsx
+++ /dev/null
@@ -1,78 +0,0 @@
-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/app/views/common/Skeleton.tsx b/FrontEnd/src/app/views/common/Skeleton.tsx
deleted file mode 100644
index 14886c71..00000000
--- a/FrontEnd/src/app/views/common/Skeleton.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-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/app/views/common/TabPages.tsx b/FrontEnd/src/app/views/common/TabPages.tsx
deleted file mode 100644
index 2b1d91cb..00000000
--- a/FrontEnd/src/app/views/common/TabPages.tsx
+++ /dev/null
@@ -1,74 +0,0 @@
-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/app/views/common/TimelineLogo.tsx b/FrontEnd/src/app/views/common/TimelineLogo.tsx
deleted file mode 100644
index 27d188fc..00000000
--- a/FrontEnd/src/app/views/common/TimelineLogo.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-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/app/views/common/ToggleIconButton.tsx b/FrontEnd/src/app/views/common/ToggleIconButton.tsx
deleted file mode 100644
index c4d2d132..00000000
--- a/FrontEnd/src/app/views/common/ToggleIconButton.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-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/app/views/common/UserTimelineLogo.tsx b/FrontEnd/src/app/views/common/UserTimelineLogo.tsx
deleted file mode 100644
index 19b9fee5..00000000
--- a/FrontEnd/src/app/views/common/UserTimelineLogo.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-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/app/views/common/alert/AlertHost.tsx b/FrontEnd/src/app/views/common/alert/AlertHost.tsx
deleted file mode 100644
index 949be7ed..00000000
--- a/FrontEnd/src/app/views/common/alert/AlertHost.tsx
+++ /dev/null
@@ -1,106 +0,0 @@
-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/app/views/common/alert/alert.sass b/FrontEnd/src/app/views/common/alert/alert.sass
deleted file mode 100644
index c3560b87..00000000
--- a/FrontEnd/src/app/views/common/alert/alert.sass
+++ /dev/null
@@ -1,15 +0,0 @@
-.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/app/views/common/common.sass b/FrontEnd/src/app/views/common/common.sass
deleted file mode 100644
index cbf7292e..00000000
--- a/FrontEnd/src/app/views/common/common.sass
+++ /dev/null
@@ -1,191 +0,0 @@
-.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/app/views/common/user/UserAvatar.tsx b/FrontEnd/src/app/views/common/user/UserAvatar.tsx
deleted file mode 100644
index 9e822528..00000000
--- a/FrontEnd/src/app/views/common/user/UserAvatar.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-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/app/views/home/TimelineListView.tsx b/FrontEnd/src/app/views/home/TimelineListView.tsx
deleted file mode 100644
index 95c3c367..00000000
--- a/FrontEnd/src/app/views/home/TimelineListView.tsx
+++ /dev/null
@@ -1,101 +0,0 @@
-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/app/views/home/WebsiteIntroduction.tsx b/FrontEnd/src/app/views/home/WebsiteIntroduction.tsx
deleted file mode 100644
index aea7b4b2..00000000
--- a/FrontEnd/src/app/views/home/WebsiteIntroduction.tsx
+++ /dev/null
@@ -1,77 +0,0 @@
-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/app/views/home/home.sass b/FrontEnd/src/app/views/home/home.sass
deleted file mode 100644
index b4cda586..00000000
--- a/FrontEnd/src/app/views/home/home.sass
+++ /dev/null
@@ -1,29 +0,0 @@
-.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/app/views/home/index.tsx b/FrontEnd/src/app/views/home/index.tsx
deleted file mode 100644
index 0eca23ee..00000000
--- a/FrontEnd/src/app/views/home/index.tsx
+++ /dev/null
@@ -1,74 +0,0 @@
-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/app/views/login/index.tsx b/FrontEnd/src/app/views/login/index.tsx
deleted file mode 100644
index 6adcef39..00000000
--- a/FrontEnd/src/app/views/login/index.tsx
+++ /dev/null
@@ -1,151 +0,0 @@
-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/app/views/login/login.sass b/FrontEnd/src/app/views/login/login.sass
deleted file mode 100644
index 0bf385f5..00000000
--- a/FrontEnd/src/app/views/login/login.sass
+++ /dev/null
@@ -1,2 +0,0 @@
-.login-container
- max-width: 600px
diff --git a/FrontEnd/src/app/views/search/index.tsx b/FrontEnd/src/app/views/search/index.tsx
deleted file mode 100644
index 966ca666..00000000
--- a/FrontEnd/src/app/views/search/index.tsx
+++ /dev/null
@@ -1,128 +0,0 @@
-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/app/views/search/search.sass b/FrontEnd/src/app/views/search/search.sass
deleted file mode 100644
index 83f297fe..00000000
--- a/FrontEnd/src/app/views/search/search.sass
+++ /dev/null
@@ -1,13 +0,0 @@
-.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/app/views/settings/ChangeAvatarDialog.tsx b/FrontEnd/src/app/views/settings/ChangeAvatarDialog.tsx
deleted file mode 100644
index c4f6f492..00000000
--- a/FrontEnd/src/app/views/settings/ChangeAvatarDialog.tsx
+++ /dev/null
@@ -1,305 +0,0 @@
-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/app/views/settings/ChangeNicknameDialog.tsx b/FrontEnd/src/app/views/settings/ChangeNicknameDialog.tsx
deleted file mode 100644
index 4b44cdd6..00000000
--- a/FrontEnd/src/app/views/settings/ChangeNicknameDialog.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-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/app/views/settings/ChangePasswordDialog.tsx b/FrontEnd/src/app/views/settings/ChangePasswordDialog.tsx
deleted file mode 100644
index 21eeeb09..00000000
--- a/FrontEnd/src/app/views/settings/ChangePasswordDialog.tsx
+++ /dev/null
@@ -1,68 +0,0 @@
-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/app/views/settings/index.tsx b/FrontEnd/src/app/views/settings/index.tsx
deleted file mode 100644
index 04a2777a..00000000
--- a/FrontEnd/src/app/views/settings/index.tsx
+++ /dev/null
@@ -1,138 +0,0 @@
-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/app/views/settings/settings.sass b/FrontEnd/src/app/views/settings/settings.sass
deleted file mode 100644
index 8c6d24b8..00000000
--- a/FrontEnd/src/app/views/settings/settings.sass
+++ /dev/null
@@ -1,14 +0,0 @@
-.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/app/views/timeline-common/CollapseButton.tsx b/FrontEnd/src/app/views/timeline-common/CollapseButton.tsx
deleted file mode 100644
index 12a3b710..00000000
--- a/FrontEnd/src/app/views/timeline-common/CollapseButton.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-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/app/views/timeline-common/ConnectionStatusBadge.tsx b/FrontEnd/src/app/views/timeline-common/ConnectionStatusBadge.tsx
deleted file mode 100644
index df43d8d2..00000000
--- a/FrontEnd/src/app/views/timeline-common/ConnectionStatusBadge.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-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/app/views/timeline-common/MarkdownPostEdit.tsx b/FrontEnd/src/app/views/timeline-common/MarkdownPostEdit.tsx
deleted file mode 100644
index 685e17be..00000000
--- a/FrontEnd/src/app/views/timeline-common/MarkdownPostEdit.tsx
+++ /dev/null
@@ -1,205 +0,0 @@
-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/app/views/timeline-common/PostPropertyChangeDialog.tsx b/FrontEnd/src/app/views/timeline-common/PostPropertyChangeDialog.tsx
deleted file mode 100644
index 001e52d7..00000000
--- a/FrontEnd/src/app/views/timeline-common/PostPropertyChangeDialog.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-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/app/views/timeline-common/Timeline.tsx b/FrontEnd/src/app/views/timeline-common/Timeline.tsx
deleted file mode 100644
index 589382b0..00000000
--- a/FrontEnd/src/app/views/timeline-common/Timeline.tsx
+++ /dev/null
@@ -1,143 +0,0 @@
-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/app/views/timeline-common/TimelineDateLabel.tsx b/FrontEnd/src/app/views/timeline-common/TimelineDateLabel.tsx
deleted file mode 100644
index 80968ee2..00000000
--- a/FrontEnd/src/app/views/timeline-common/TimelineDateLabel.tsx
+++ /dev/null
@@ -1,19 +0,0 @@
-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/app/views/timeline-common/TimelineLine.tsx b/FrontEnd/src/app/views/timeline-common/TimelineLine.tsx
deleted file mode 100644
index 0a828b32..00000000
--- a/FrontEnd/src/app/views/timeline-common/TimelineLine.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-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/app/views/timeline-common/TimelineLoading.tsx b/FrontEnd/src/app/views/timeline-common/TimelineLoading.tsx
deleted file mode 100644
index fc42f4b4..00000000
--- a/FrontEnd/src/app/views/timeline-common/TimelineLoading.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-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/app/views/timeline-common/TimelineMember.tsx b/FrontEnd/src/app/views/timeline-common/TimelineMember.tsx
deleted file mode 100644
index 299d6a53..00000000
--- a/FrontEnd/src/app/views/timeline-common/TimelineMember.tsx
+++ /dev/null
@@ -1,195 +0,0 @@
-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/app/views/timeline-common/TimelinePageCardTemplate.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePageCardTemplate.tsx
deleted file mode 100644
index 623d643f..00000000
--- a/FrontEnd/src/app/views/timeline-common/TimelinePageCardTemplate.tsx
+++ /dev/null
@@ -1,158 +0,0 @@
-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/app/views/timeline-common/TimelinePageTemplate.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx
deleted file mode 100644
index 658ce502..00000000
--- a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx
+++ /dev/null
@@ -1,190 +0,0 @@
-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/app/views/timeline-common/TimelinePagedPostListView.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePagedPostListView.tsx
deleted file mode 100644
index 37f02a82..00000000
--- a/FrontEnd/src/app/views/timeline-common/TimelinePagedPostListView.tsx
+++ /dev/null
@@ -1,43 +0,0 @@
-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/app/views/timeline-common/TimelinePostContentView.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePostContentView.tsx
deleted file mode 100644
index 607b72c9..00000000
--- a/FrontEnd/src/app/views/timeline-common/TimelinePostContentView.tsx
+++ /dev/null
@@ -1,197 +0,0 @@
-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/app/views/timeline-common/TimelinePostDeleteConfirmDialog.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePostDeleteConfirmDialog.tsx
deleted file mode 100644
index b2c7a470..00000000
--- a/FrontEnd/src/app/views/timeline-common/TimelinePostDeleteConfirmDialog.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-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/app/views/timeline-common/TimelinePostEdit.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePostEdit.tsx
deleted file mode 100644
index 5f3f0345..00000000
--- a/FrontEnd/src/app/views/timeline-common/TimelinePostEdit.tsx
+++ /dev/null
@@ -1,291 +0,0 @@
-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/app/views/timeline-common/TimelinePostListView.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePostListView.tsx
deleted file mode 100644
index ba204b72..00000000
--- a/FrontEnd/src/app/views/timeline-common/TimelinePostListView.tsx
+++ /dev/null
@@ -1,79 +0,0 @@
-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/app/views/timeline-common/TimelinePostView.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePostView.tsx
deleted file mode 100644
index f7b81478..00000000
--- a/FrontEnd/src/app/views/timeline-common/TimelinePostView.tsx
+++ /dev/null
@@ -1,151 +0,0 @@
-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/app/views/timeline-common/TimelinePropertyChangeDialog.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx
deleted file mode 100644
index 70f72025..00000000
--- a/FrontEnd/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx
+++ /dev/null
@@ -1,87 +0,0 @@
-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/app/views/timeline-common/TimelineTop.tsx b/FrontEnd/src/app/views/timeline-common/TimelineTop.tsx
deleted file mode 100644
index dabbdf1e..00000000
--- a/FrontEnd/src/app/views/timeline-common/TimelineTop.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-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/app/views/timeline-common/timeline-common.sass b/FrontEnd/src/app/views/timeline-common/timeline-common.sass
deleted file mode 100644
index 4400fead..00000000
--- a/FrontEnd/src/app/views/timeline-common/timeline-common.sass
+++ /dev/null
@@ -1,259 +0,0 @@
-@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/app/views/timeline/TimelineCard.tsx b/FrontEnd/src/app/views/timeline/TimelineCard.tsx
deleted file mode 100644
index e031b565..00000000
--- a/FrontEnd/src/app/views/timeline/TimelineCard.tsx
+++ /dev/null
@@ -1,74 +0,0 @@
-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/app/views/timeline/TimelineDeleteDialog.tsx b/FrontEnd/src/app/views/timeline/TimelineDeleteDialog.tsx
deleted file mode 100644
index dbca62ca..00000000
--- a/FrontEnd/src/app/views/timeline/TimelineDeleteDialog.tsx
+++ /dev/null
@@ -1,55 +0,0 @@
-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/app/views/timeline/index.tsx b/FrontEnd/src/app/views/timeline/index.tsx
deleted file mode 100644
index c5bfd7ab..00000000
--- a/FrontEnd/src/app/views/timeline/index.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-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/app/views/timeline/timeline.sass b/FrontEnd/src/app/views/timeline/timeline.sass
deleted file mode 100644
index e69de29b..00000000
--- a/FrontEnd/src/app/views/timeline/timeline.sass
+++ /dev/null
diff --git a/FrontEnd/src/app/views/user/UserCard.tsx b/FrontEnd/src/app/views/user/UserCard.tsx
deleted file mode 100644
index e7e4252e..00000000
--- a/FrontEnd/src/app/views/user/UserCard.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-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/app/views/user/index.tsx b/FrontEnd/src/app/views/user/index.tsx
deleted file mode 100644
index 57454d0d..00000000
--- a/FrontEnd/src/app/views/user/index.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-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/app/views/user/user.sass b/FrontEnd/src/app/views/user/user.sass
deleted file mode 100644
index 63a28e05..00000000
--- a/FrontEnd/src/app/views/user/user.sass
+++ /dev/null
@@ -1,7 +0,0 @@
-.change-avatar-cropper-row
- max-height: 400px
-
-.change-avatar-img
- min-width: 50%
- max-width: 100%
- max-height: 400px