aboutsummaryrefslogtreecommitdiff
path: root/Timeline/ClientApp/src/app/views
diff options
context:
space:
mode:
Diffstat (limited to 'Timeline/ClientApp/src/app/views')
-rw-r--r--Timeline/ClientApp/src/app/views/about/about.sass4
-rw-r--r--Timeline/ClientApp/src/app/views/about/author-avatar.pngbin12038 -> 0 bytes
-rw-r--r--Timeline/ClientApp/src/app/views/about/github.pngbin4268 -> 0 bytes
-rw-r--r--Timeline/ClientApp/src/app/views/about/index.tsx164
-rw-r--r--Timeline/ClientApp/src/app/views/admin/Admin.tsx75
-rw-r--r--Timeline/ClientApp/src/app/views/admin/UserAdmin.tsx460
-rw-r--r--Timeline/ClientApp/src/app/views/common/AppBar.tsx64
-rw-r--r--Timeline/ClientApp/src/app/views/common/BlobImage.tsx27
-rw-r--r--Timeline/ClientApp/src/app/views/common/ImageCropper.tsx306
-rw-r--r--Timeline/ClientApp/src/app/views/common/LoadingButton.tsx29
-rw-r--r--Timeline/ClientApp/src/app/views/common/LoadingPage.tsx12
-rw-r--r--Timeline/ClientApp/src/app/views/common/OperationDialog.tsx364
-rw-r--r--Timeline/ClientApp/src/app/views/common/SearchInput.tsx63
-rw-r--r--Timeline/ClientApp/src/app/views/common/TimelineLogo.tsx26
-rw-r--r--Timeline/ClientApp/src/app/views/common/UserTimelineLogo.tsx26
-rw-r--r--Timeline/ClientApp/src/app/views/common/alert/AlertHost.tsx101
-rw-r--r--Timeline/ClientApp/src/app/views/common/alert/alert.sass15
-rw-r--r--Timeline/ClientApp/src/app/views/common/common.sass33
-rw-r--r--Timeline/ClientApp/src/app/views/home/BoardWithUser.tsx101
-rw-r--r--Timeline/ClientApp/src/app/views/home/BoardWithoutUser.tsx60
-rw-r--r--Timeline/ClientApp/src/app/views/home/OfflineBoard.tsx61
-rw-r--r--Timeline/ClientApp/src/app/views/home/TimelineBoard.tsx73
-rw-r--r--Timeline/ClientApp/src/app/views/home/TimelineCreateDialog.tsx53
-rw-r--r--Timeline/ClientApp/src/app/views/home/home.sass13
-rw-r--r--Timeline/ClientApp/src/app/views/home/index.tsx99
-rw-r--r--Timeline/ClientApp/src/app/views/login/index.tsx151
-rw-r--r--Timeline/ClientApp/src/app/views/login/login.sass2
-rw-r--r--Timeline/ClientApp/src/app/views/settings/index.tsx209
-rw-r--r--Timeline/ClientApp/src/app/views/timeline-common/CollapseButton.tsx23
-rw-r--r--Timeline/ClientApp/src/app/views/timeline-common/InfoCardTemplate.tsx26
-rw-r--r--Timeline/ClientApp/src/app/views/timeline-common/SyncStatusBadge.tsx58
-rw-r--r--Timeline/ClientApp/src/app/views/timeline-common/Timeline.tsx84
-rw-r--r--Timeline/ClientApp/src/app/views/timeline-common/TimelineItem.tsx172
-rw-r--r--Timeline/ClientApp/src/app/views/timeline-common/TimelineMember.tsx211
-rw-r--r--Timeline/ClientApp/src/app/views/timeline-common/TimelinePageTemplate.tsx185
-rw-r--r--Timeline/ClientApp/src/app/views/timeline-common/TimelinePageTemplateUI.tsx243
-rw-r--r--Timeline/ClientApp/src/app/views/timeline-common/TimelinePostEdit.tsx241
-rw-r--r--Timeline/ClientApp/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx72
-rw-r--r--Timeline/ClientApp/src/app/views/timeline-common/TimelineTop.tsx21
-rw-r--r--Timeline/ClientApp/src/app/views/timeline-common/timeline-common.sass146
-rw-r--r--Timeline/ClientApp/src/app/views/timeline/TimelineDeleteDialog.tsx55
-rw-r--r--Timeline/ClientApp/src/app/views/timeline/TimelineInfoCard.tsx85
-rw-r--r--Timeline/ClientApp/src/app/views/timeline/TimelinePageUI.tsx20
-rw-r--r--Timeline/ClientApp/src/app/views/timeline/index.tsx37
-rw-r--r--Timeline/ClientApp/src/app/views/timeline/timeline.sass0
-rw-r--r--Timeline/ClientApp/src/app/views/user/ChangeAvatarDialog.tsx302
-rw-r--r--Timeline/ClientApp/src/app/views/user/ChangeNicknameDialog.tsx28
-rw-r--r--Timeline/ClientApp/src/app/views/user/UserInfoCard.tsx80
-rw-r--r--Timeline/ClientApp/src/app/views/user/UserPageUI.tsx18
-rw-r--r--Timeline/ClientApp/src/app/views/user/index.tsx72
-rw-r--r--Timeline/ClientApp/src/app/views/user/user.sass7
51 files changed, 0 insertions, 4777 deletions
diff --git a/Timeline/ClientApp/src/app/views/about/about.sass b/Timeline/ClientApp/src/app/views/about/about.sass
deleted file mode 100644
index 3b5840cd..00000000
--- a/Timeline/ClientApp/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/Timeline/ClientApp/src/app/views/about/author-avatar.png b/Timeline/ClientApp/src/app/views/about/author-avatar.png
deleted file mode 100644
index d890d8d0..00000000
--- a/Timeline/ClientApp/src/app/views/about/author-avatar.png
+++ /dev/null
Binary files differ
diff --git a/Timeline/ClientApp/src/app/views/about/github.png b/Timeline/ClientApp/src/app/views/about/github.png
deleted file mode 100644
index ea6ff545..00000000
--- a/Timeline/ClientApp/src/app/views/about/github.png
+++ /dev/null
Binary files differ
diff --git a/Timeline/ClientApp/src/app/views/about/index.tsx b/Timeline/ClientApp/src/app/views/about/index.tsx
deleted file mode 100644
index e7771cec..00000000
--- a/Timeline/ClientApp/src/app/views/about/index.tsx
+++ /dev/null
@@ -1,164 +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: "babeljs",
- url: "https://babeljs.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",
- },
- {
- name: "react-inlinesvg",
- url: "https://github.com/gilbarbara/react-inlinesvg",
- },
-];
-
-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="mt-appbar px-2 mb-4">
- <div className="container mt-4 py-3 shadow border border-primary rounded bg-light">
- <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 shadow border border-primary rounded bg-light">
- <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 shadow border border-primary rounded bg-light">
- <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/Timeline/ClientApp/src/app/views/admin/Admin.tsx b/Timeline/ClientApp/src/app/views/admin/Admin.tsx
deleted file mode 100644
index 9c0250e7..00000000
--- a/Timeline/ClientApp/src/app/views/admin/Admin.tsx
+++ /dev/null
@@ -1,75 +0,0 @@
-import React, { Fragment } from "react";
-import {
- Redirect,
- Route,
- Switch,
- useRouteMatch,
- useHistory,
-} from "react-router";
-import { Nav } from "react-bootstrap";
-
-import { UserWithToken } from "@/services/user";
-
-import UserAdmin from "./UserAdmin";
-
-interface AdminProps {
- user: UserWithToken;
-}
-
-const Admin: React.FC<AdminProps> = (props) => {
- const match = useRouteMatch();
- const history = useHistory();
- type TabNames = "users" | "more";
-
- const tabName = history.location.pathname.replace(match.path + "/", "");
-
- function toggle(newTab: TabNames): void {
- history.push(`${match.url}/${newTab}`);
- }
-
- const createRoute = (
- name: string,
- body: React.ReactNode
- ): React.ReactNode => {
- return (
- <Route path={`${match.path}/${name}`}>
- <div style={{ height: 56 }} className="flex-fix-length" />
- <Nav variant="tabs">
- <Nav.Item>
- <Nav.Link
- active={tabName === "users"}
- onClick={() => {
- toggle("users");
- }}
- >
- Users
- </Nav.Link>
- </Nav.Item>
- <Nav.Item>
- <Nav.Link
- active={tabName === "more"}
- onClick={() => {
- toggle("more");
- }}
- >
- More
- </Nav.Link>
- </Nav.Item>
- </Nav>
- {body}
- </Route>
- );
- };
-
- return (
- <Fragment>
- <Switch>
- <Redirect from={match.path} to={`${match.path}/users`} exact />
- {createRoute("users", <UserAdmin user={props.user} />)}
- {createRoute("more", <div>More Page Works</div>)}
- </Switch>
- </Fragment>
- );
-};
-
-export default Admin;
diff --git a/Timeline/ClientApp/src/app/views/admin/UserAdmin.tsx b/Timeline/ClientApp/src/app/views/admin/UserAdmin.tsx
deleted file mode 100644
index 18b77ca8..00000000
--- a/Timeline/ClientApp/src/app/views/admin/UserAdmin.tsx
+++ /dev/null
@@ -1,460 +0,0 @@
-import React, { useState, useEffect } from "react";
-import axios from "axios";
-import {
- ListGroup,
- Row,
- Col,
- Dropdown,
- Spinner,
- Button,
-} from "react-bootstrap";
-
-import OperationDialog from "../common/OperationDialog";
-import { User, UserWithToken } from "@/services/user";
-
-const apiBaseUrl = "/api";
-
-async function fetchUserList(_token: string): Promise<User[]> {
- const res = await axios.get<User[]>(`${apiBaseUrl}/users`);
- return res.data;
-}
-
-interface CreateUserInfo {
- username: string;
- password: string;
- administrator: boolean;
-}
-
-async function createUser(user: CreateUserInfo, token: string): Promise<User> {
- const res = await axios.post<User>(
- `${apiBaseUrl}/userop/createuser?token=${token}`,
- user
- );
- return res.data;
-}
-
-function deleteUser(username: string, token: string): Promise<void> {
- return axios.delete(`${apiBaseUrl}/users/${username}?token=${token}`);
-}
-
-function changeUsername(
- oldUsername: string,
- newUsername: string,
- token: string
-): Promise<void> {
- return axios.patch(`${apiBaseUrl}/users/${oldUsername}?token=${token}`, {
- username: newUsername,
- });
-}
-
-function changePassword(
- username: string,
- newPassword: string,
- token: string
-): Promise<void> {
- return axios.patch(`${apiBaseUrl}/users/${username}?token=${token}`, {
- password: newPassword,
- });
-}
-
-function changePermission(
- username: string,
- newPermission: boolean,
- token: string
-): Promise<void> {
- return axios.patch(`${apiBaseUrl}/users/${username}?token=${token}`, {
- administrator: newPermission,
- });
-}
-
-const kChangeUsername = "changeusername";
-const kChangePassword = "changepassword";
-const kChangePermission = "changepermission";
-const kDelete = "delete";
-
-type TChangeUsername = typeof kChangeUsername;
-type TChangePassword = typeof kChangePassword;
-type TChangePermission = typeof kChangePermission;
-type TDelete = typeof kDelete;
-
-type ContextMenuItem =
- | TChangeUsername
- | TChangePassword
- | TChangePermission
- | TDelete;
-
-interface UserCardProps {
- onContextMenu: (item: ContextMenuItem) => void;
- user: User;
-}
-
-const UserItem: React.FC<UserCardProps> = (props) => {
- const user = props.user;
-
- const createClickCallback = (item: ContextMenuItem): (() => void) => {
- return () => {
- props.onContextMenu(item);
- };
- };
-
- return (
- <ListGroup.Item className="container">
- <Row className="align-items-center">
- <Col>
- <p className="mb-0 text-primary">{user.username}</p>
- <small
- className={user.administrator ? "text-danger" : "text-secondary"}
- >
- {user.administrator ? "administrator" : "user"}
- </small>
- </Col>
- <Col className="col-auto">
- <Dropdown>
- <Dropdown.Toggle variant="warning" className="text-light">
- Manage
- </Dropdown.Toggle>
- <Dropdown.Menu>
- <Dropdown.Item onClick={createClickCallback(kChangeUsername)}>
- Change Username
- </Dropdown.Item>
- <Dropdown.Item onClick={createClickCallback(kChangePassword)}>
- Change Password
- </Dropdown.Item>
- <Dropdown.Item onClick={createClickCallback(kChangePermission)}>
- Change Permission
- </Dropdown.Item>
- <Dropdown.Item
- className="text-danger"
- onClick={createClickCallback(kDelete)}
- >
- Delete
- </Dropdown.Item>
- </Dropdown.Menu>
- </Dropdown>
- </Col>
- </Row>
- </ListGroup.Item>
- );
-};
-
-interface DialogProps {
- open: boolean;
- close: () => void;
-}
-
-interface CreateUserDialogProps extends DialogProps {
- process: (user: CreateUserInfo) => Promise<void>;
-}
-
-const CreateUserDialog: React.FC<CreateUserDialogProps> = (props) => {
- return (
- <OperationDialog
- title="Create"
- titleColor="create"
- inputPrompt="You are creating a new user."
- inputScheme={[
- { type: "text", label: "Username" },
- { type: "text", label: "Password" },
- { type: "bool", label: "Administrator" },
- ]}
- onProcess={([username, password, administrator]) =>
- props.process({
- username: username as string,
- password: password as string,
- administrator: administrator as boolean,
- })
- }
- close={props.close}
- open={props.open}
- />
- );
-};
-
-const UsernameLabel: React.FC = (props) => {
- return <span style={{ color: "blue" }}>{props.children}</span>;
-};
-
-interface UserDeleteDialogProps extends DialogProps {
- username: string;
- process: () => Promise<void>;
-}
-
-const UserDeleteDialog: React.FC<UserDeleteDialogProps> = (props) => {
- return (
- <OperationDialog
- open={props.open}
- close={props.close}
- title="Dangerous"
- titleColor="dangerous"
- inputPrompt={() => (
- <>
- {"You are deleting user "}
- <UsernameLabel>{props.username}</UsernameLabel>
- {" !"}
- </>
- )}
- onProcess={props.process}
- />
- );
-};
-
-interface UserModifyDialogProps<T> extends DialogProps {
- username: string;
- process: (value: T) => Promise<void>;
-}
-
-const UserChangeUsernameDialog: React.FC<UserModifyDialogProps<string>> = (
- props
-) => {
- return (
- <OperationDialog
- open={props.open}
- close={props.close}
- title="Caution"
- titleColor="dangerous"
- inputPrompt={() => (
- <>
- {"You are change the username of user "}
- <UsernameLabel>{props.username}</UsernameLabel>
- {" !"}
- </>
- )}
- inputScheme={[{ type: "text", label: "New Username" }]}
- onProcess={([newUsername]) => {
- return props.process(newUsername as string);
- }}
- />
- );
-};
-
-const UserChangePasswordDialog: React.FC<UserModifyDialogProps<string>> = (
- props
-) => {
- return (
- <OperationDialog
- open={props.open}
- close={props.close}
- title="Caution"
- titleColor="dangerous"
- inputPrompt={() => (
- <>
- {"You are change the password of user "}
- <UsernameLabel>{props.username}</UsernameLabel>
- {" !"}
- </>
- )}
- inputScheme={[{ type: "text", label: "New Password" }]}
- onProcess={([newPassword]) => {
- return props.process(newPassword as string);
- }}
- />
- );
-};
-
-interface UserChangePermissionDialogProps extends DialogProps {
- username: string;
- newPermission: boolean;
- process: () => Promise<void>;
-}
-
-const UserChangePermissionDialog: React.FC<UserChangePermissionDialogProps> = (
- props
-) => {
- return (
- <OperationDialog
- open={props.open}
- close={props.close}
- title="Caution"
- titleColor="dangerous"
- inputPrompt={() => (
- <>
- {"You are change user "}
- <UsernameLabel>{props.username}</UsernameLabel>
- {" to "}
- <span style={{ color: "orange" }}>
- {props.newPermission ? "administrator" : "normal user"}
- </span>
- {" !"}
- </>
- )}
- onProcess={props.process}
- />
- );
-};
-
-interface UserAdminProps {
- user: UserWithToken;
-}
-
-const UserAdmin: React.FC<UserAdminProps> = (props) => {
- type DialogInfo =
- | null
- | {
- type: "create";
- }
- | { type: TDelete; username: string }
- | {
- type: TChangeUsername;
- username: string;
- }
- | {
- type: TChangePassword;
- username: string;
- }
- | {
- type: TChangePermission;
- username: string;
- newPermission: boolean;
- };
-
- const [users, setUsers] = useState<User[] | null>(null);
- const [dialog, setDialog] = useState<DialogInfo>(null);
-
- const token = props.user.token;
-
- useEffect(() => {
- let subscribe = true;
- void fetchUserList(props.user.token).then((us) => {
- if (subscribe) {
- setUsers(us);
- }
- });
- return () => {
- subscribe = false;
- };
- }, [props.user]);
-
- let dialogNode: React.ReactNode;
- if (dialog)
- switch (dialog.type) {
- case "create":
- dialogNode = (
- <CreateUserDialog
- open
- close={() => setDialog(null)}
- process={async (user) => {
- const u = await createUser(user, token);
- setUsers((oldUsers) => [...(oldUsers ?? []), u]);
- }}
- />
- );
- break;
- case "delete":
- dialogNode = (
- <UserDeleteDialog
- open
- close={() => setDialog(null)}
- username={dialog.username}
- process={async () => {
- await deleteUser(dialog.username, token);
- setUsers((oldUsers) =>
- (oldUsers ?? []).filter((u) => u.username !== dialog.username)
- );
- }}
- />
- );
- break;
- case kChangeUsername:
- dialogNode = (
- <UserChangeUsernameDialog
- open
- close={() => setDialog(null)}
- username={dialog.username}
- process={async (newUsername) => {
- await changeUsername(dialog.username, newUsername, token);
- setUsers((oldUsers) => {
- const users = (oldUsers ?? []).slice();
- const findedUser = users.find(
- (u) => u.username === dialog.username
- );
- if (findedUser) findedUser.username = newUsername;
- return users;
- });
- }}
- />
- );
- break;
- case kChangePassword:
- dialogNode = (
- <UserChangePasswordDialog
- open
- close={() => setDialog(null)}
- username={dialog.username}
- process={async (newPassword) => {
- await changePassword(dialog.username, newPassword, token);
- }}
- />
- );
- break;
- case kChangePermission: {
- const newPermission = dialog.newPermission;
- dialogNode = (
- <UserChangePermissionDialog
- open
- close={() => setDialog(null)}
- username={dialog.username}
- newPermission={newPermission}
- process={async () => {
- await changePermission(dialog.username, newPermission, token);
- setUsers((oldUsers) => {
- const users = (oldUsers ?? []).slice();
- const findedUser = users.find(
- (u) => u.username === dialog.username
- );
- if (findedUser) findedUser.administrator = newPermission;
- return users;
- });
- }}
- />
- );
- break;
- }
- }
-
- if (users) {
- const userComponents = users.map((user) => {
- return (
- <UserItem
- key={user.username}
- user={user}
- onContextMenu={(item) => {
- setDialog(
- item === kChangePermission
- ? {
- type: kChangePermission,
- username: user.username,
- newPermission: !user.administrator,
- }
- : {
- type: item,
- username: user.username,
- }
- );
- }}
- />
- );
- });
-
- return (
- <>
- <Button
- variant="success"
- onClick={() =>
- setDialog({
- type: "create",
- })
- }
- className="align-self-end"
- >
- Create User
- </Button>
- {userComponents}
- {dialogNode}
- </>
- );
- } else {
- return <Spinner animation="border" />;
- }
-};
-
-export default UserAdmin;
diff --git a/Timeline/ClientApp/src/app/views/common/AppBar.tsx b/Timeline/ClientApp/src/app/views/common/AppBar.tsx
deleted file mode 100644
index ee4ead8f..00000000
--- a/Timeline/ClientApp/src/app/views/common/AppBar.tsx
+++ /dev/null
@@ -1,64 +0,0 @@
-import React from "react";
-import { useTranslation } from "react-i18next";
-import { LinkContainer } from "react-router-bootstrap";
-import { Navbar, Nav } from "react-bootstrap";
-
-import { useUser, useAvatar } from "@/services/user";
-
-import TimelineLogo from "./TimelineLogo";
-import BlobImage from "./BlobImage";
-
-const AppBar: React.FC = (_) => {
- const user = useUser();
- const avatar = useAvatar(user?.username);
-
- const { t } = useTranslation();
-
- const isAdministrator = user && user.administrator;
-
- return (
- <Navbar bg="primary" variant="dark" expand="md" sticky="top">
- <LinkContainer to="/">
- <Navbar.Brand className="d-flex align-items-center">
- <TimelineLogo style={{ height: "1em" }} />
- Timeline
- </Navbar.Brand>
- </LinkContainer>
-
- <Navbar.Toggle />
- <Navbar.Collapse>
- <Nav className="mr-auto">
- <LinkContainer to="/settings">
- <Nav.Link>{t("nav.settings")}</Nav.Link>
- </LinkContainer>
-
- <LinkContainer to="/about">
- <Nav.Link>{t("nav.about")}</Nav.Link>
- </LinkContainer>
-
- {isAdministrator && (
- <LinkContainer to="/admin">
- <Nav.Link>Administration</Nav.Link>
- </LinkContainer>
- )}
- </Nav>
- <Nav className="ml-auto mr-2">
- {user != null ? (
- <LinkContainer to={`/users/${user.username}`}>
- <BlobImage
- className="avatar small rounded-circle bg-white"
- blob={avatar}
- />
- </LinkContainer>
- ) : (
- <LinkContainer to="/login">
- <Nav.Link>{t("nav.login")}</Nav.Link>
- </LinkContainer>
- )}
- </Nav>
- </Navbar.Collapse>
- </Navbar>
- );
-};
-
-export default AppBar;
diff --git a/Timeline/ClientApp/src/app/views/common/BlobImage.tsx b/Timeline/ClientApp/src/app/views/common/BlobImage.tsx
deleted file mode 100644
index 0dd25c52..00000000
--- a/Timeline/ClientApp/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/Timeline/ClientApp/src/app/views/common/ImageCropper.tsx b/Timeline/ClientApp/src/app/views/common/ImageCropper.tsx
deleted file mode 100644
index b9db8b99..00000000
--- a/Timeline/ClientApp/src/app/views/common/ImageCropper.tsx
+++ /dev/null
@@ -1,306 +0,0 @@
-import React from "react";
-import clsx from "clsx";
-
-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={clsx("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/Timeline/ClientApp/src/app/views/common/LoadingButton.tsx b/Timeline/ClientApp/src/app/views/common/LoadingButton.tsx
deleted file mode 100644
index 154334a7..00000000
--- a/Timeline/ClientApp/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="ml-1"
- variant={variant}
- animation="grow"
- size="sm"
- />
- ) : null}
- </Button>
- );
-};
-
-export default LoadingButton;
diff --git a/Timeline/ClientApp/src/app/views/common/LoadingPage.tsx b/Timeline/ClientApp/src/app/views/common/LoadingPage.tsx
deleted file mode 100644
index 590fafa0..00000000
--- a/Timeline/ClientApp/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/Timeline/ClientApp/src/app/views/common/OperationDialog.tsx b/Timeline/ClientApp/src/app/views/common/OperationDialog.tsx
deleted file mode 100644
index 841392a6..00000000
--- a/Timeline/ClientApp/src/app/views/common/OperationDialog.tsx
+++ /dev/null
@@ -1,364 +0,0 @@
-import React, { useState } from "react";
-import { useTranslation } from "react-i18next";
-import { Form, Button, Modal } from "react-bootstrap";
-
-import { 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 type OperationInputOptionalError = undefined | null | string;
-
-export interface OperationInputErrorInfo {
- [index: number]: OperationInputOptionalError;
-}
-
-export type OperationInputValidator<TValue> = (
- value: TValue,
- values: (string | boolean)[]
-) => OperationInputOptionalError | OperationInputErrorInfo;
-
-export interface OperationTextInputInfo {
- type: "text";
- password?: boolean;
- label?: string;
- initValue?: string;
- textFieldProps?: Omit<
- React.InputHTMLAttributes<HTMLInputElement>,
- "type" | "value" | "onChange" | "aria-relevant"
- >;
- helperText?: string;
- validator?: OperationInputValidator<string>;
-}
-
-export interface OperationBoolInputInfo {
- type: "bool";
- label: string;
- initValue?: boolean;
-}
-
-export interface OperationSelectInputInfoOption {
- value: string;
- label: string;
- icon?: React.ReactElement;
-}
-
-export interface OperationSelectInputInfo {
- type: "select";
- label: string;
- options: OperationSelectInputInfoOption[];
- initValue?: string;
-}
-
-export type OperationInputInfo =
- | OperationTextInputInfo
- | OperationBoolInputInfo
- | OperationSelectInputInfo;
-
-interface OperationResult {
- type: "success" | "failure";
- data: unknown;
-}
-
-interface OperationDialogProps {
- open: boolean;
- close: () => void;
- title: React.ReactNode;
- titleColor?: "default" | "dangerous" | "create" | string;
- onProcess: (inputs: (string | boolean)[]) => Promise<unknown>;
- inputScheme?: OperationInputInfo[];
- inputPrompt?: string | (() => React.ReactNode);
- processPrompt?: () => React.ReactNode;
- successPrompt?: (data: unknown) => React.ReactNode;
- failurePrompt?: (error: unknown) => React.ReactNode;
- onSuccessAndClose?: () => void;
-}
-
-const OperationDialog: React.FC<OperationDialogProps> = (props) => {
- const inputScheme = props.inputScheme ?? [];
-
- const { t } = useTranslation();
-
- type Step = "input" | "process" | OperationResult;
- const [step, setStep] = useState<Step>("input");
- const [values, setValues] = useState<(boolean | string)[]>(
- inputScheme.map((i) => {
- if (i.type === "bool") {
- return i.initValue ?? false;
- } else if (i.type === "text" || i.type === "select") {
- return i.initValue ?? "";
- } else {
- throw new UiLogicError("Unknown input scheme.");
- }
- })
- );
- const [inputError, setInputError] = useState<OperationInputErrorInfo>({});
-
- const close = (): void => {
- if (step !== "process") {
- props.close();
- if (
- typeof step === "object" &&
- step.type === "success" &&
- props.onSuccessAndClose
- ) {
- props.onSuccessAndClose();
- }
- } else {
- console.log("Attempt to close modal when processing.");
- }
- };
-
- const onConfirm = (): void => {
- setStep("process");
- props.onProcess(values).then(
- (d: unknown) => {
- setStep({
- type: "success",
- data: d,
- });
- },
- (e: unknown) => {
- setStep({
- type: "failure",
- data: e,
- });
- }
- );
- };
-
- let body: React.ReactNode;
- if (step === "input" || step === "process") {
- const process = step === "process";
-
- let inputPrompt =
- typeof props.inputPrompt === "function"
- ? props.inputPrompt()
- : props.inputPrompt;
- inputPrompt = <h6>{inputPrompt}</h6>;
-
- const updateValue = (
- index: number,
- newValue: string | boolean
- ): (string | boolean)[] => {
- const oldValues = values;
- const newValues = oldValues.slice();
- newValues[index] = newValue;
- setValues(newValues);
- return newValues;
- };
-
- const testErrorInfo = (errorInfo: OperationInputErrorInfo): boolean => {
- for (let i = 0; i < inputScheme.length; i++) {
- if (inputScheme[i].type === "text" && errorInfo[i] != null) {
- return true;
- }
- }
- return false;
- };
-
- const calculateError = (
- oldError: OperationInputErrorInfo,
- index: number,
- newError: OperationInputOptionalError | OperationInputErrorInfo
- ): OperationInputErrorInfo => {
- if (newError === undefined) {
- return oldError;
- } else if (newError === null || typeof newError === "string") {
- return { ...oldError, [index]: newError };
- } else {
- const newInputError: OperationInputErrorInfo = { ...oldError };
- for (const [index, error] of Object.entries(newError)) {
- if (error !== undefined) {
- newInputError[+index] = error as OperationInputOptionalError;
- }
- }
- return newInputError;
- }
- };
-
- const validateAll = (): boolean => {
- let newInputError = inputError;
- for (let i = 0; i < inputScheme.length; i++) {
- const item = inputScheme[i];
- if (item.type === "text") {
- newInputError = calculateError(
- newInputError,
- i,
- item.validator?.(values[i] as string, values)
- );
- }
- }
- const result = !testErrorInfo(newInputError);
- setInputError(newInputError);
- return result;
- };
-
- body = (
- <>
- <Modal.Body>
- {inputPrompt}
- {inputScheme.map((item, index) => {
- const value = values[index];
- const error: string | undefined = ((e) =>
- typeof e === "string" ? t(e) : undefined)(inputError?.[index]);
-
- if (item.type === "text") {
- return (
- <Form.Group key={index}>
- {item.label && <Form.Label>{t(item.label)}</Form.Label>}
- <Form.Control
- type={item.password === true ? "password" : "text"}
- value={value as string}
- onChange={(e) => {
- const v = e.target.value;
- const newValues = updateValue(index, v);
- setInputError(
- calculateError(
- inputError,
- index,
- item.validator?.(v, newValues)
- )
- );
- }}
- 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={t(item.label)}
- disabled={process}
- />
- </Form.Group>
- );
- } else if (item.type === "select") {
- return (
- <Form.Group key={index}>
- <Form.Label>{t(item.label)}</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}
- {t(option.label)}
- </option>
- );
- })}
- </Form.Control>
- </Form.Group>
- );
- }
- })}
- </Modal.Body>
- <Modal.Footer>
- <Button variant="outline-secondary" onClick={close}>
- {t("operationDialog.cancel")}
- </Button>
- <LoadingButton
- variant="primary"
- loading={process}
- disabled={testErrorInfo(inputError)}
- onClick={() => {
- if (validateAll()) {
- 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 === "string" ? t(props.title) : props.title;
-
- return (
- <Modal show={props.open} onHide={close}>
- <Modal.Header
- className={
- props.titleColor != null
- ? "text-" +
- (props.titleColor === "create"
- ? "success"
- : props.titleColor === "dangerous"
- ? "danger"
- : props.titleColor)
- : undefined
- }
- >
- {title}
- </Modal.Header>
- {body}
- </Modal>
- );
-};
-
-export default OperationDialog;
diff --git a/Timeline/ClientApp/src/app/views/common/SearchInput.tsx b/Timeline/ClientApp/src/app/views/common/SearchInput.tsx
deleted file mode 100644
index 9833d515..00000000
--- a/Timeline/ClientApp/src/app/views/common/SearchInput.tsx
+++ /dev/null
@@ -1,63 +0,0 @@
-import React, { useCallback } from "react";
-import clsx from "clsx";
-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;
-}
-
-const SearchInput: React.FC<SearchInputProps> = (props) => {
- const { onChange, onButtonClick } = 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();
- }
- },
- [onButtonClick]
- );
-
- return (
- <Form inline className={clsx("my-2", props.className)}>
- <Form.Control
- className="mr-sm-2 flex-grow-1"
- value={props.value}
- onChange={onInputChange}
- onKeyPress={onInputKeyPress}
- placeholder={props.placeholder}
- />
- <div className="mt-2 mt-sm-0 order-sm-last ml-sm-3">
- {props.additionalButton}
- </div>
- <div className="mt-2 mt-sm-0 ml-auto ml-sm-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/Timeline/ClientApp/src/app/views/common/TimelineLogo.tsx b/Timeline/ClientApp/src/app/views/common/TimelineLogo.tsx
deleted file mode 100644
index 27d188fc..00000000
--- a/Timeline/ClientApp/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/Timeline/ClientApp/src/app/views/common/UserTimelineLogo.tsx b/Timeline/ClientApp/src/app/views/common/UserTimelineLogo.tsx
deleted file mode 100644
index 29f6a69f..00000000
--- a/Timeline/ClientApp/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={color}>
- <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/Timeline/ClientApp/src/app/views/common/alert/AlertHost.tsx b/Timeline/ClientApp/src/app/views/common/alert/AlertHost.tsx
deleted file mode 100644
index c74f18e2..00000000
--- a/Timeline/ClientApp/src/app/views/common/alert/AlertHost.tsx
+++ /dev/null
@@ -1,101 +0,0 @@
-import React, { useCallback } from "react";
-import without from "lodash/without";
-import concat from "lodash/concat";
-import { useTranslation } from "react-i18next";
-import { Alert } from "react-bootstrap";
-
-import {
- alertService,
- AlertInfoEx,
- kAlertHostId,
- AlertInfo,
-} from "@/services/alert";
-
-interface AutoCloseAlertProps {
- alert: AlertInfo;
- close: () => void;
-}
-
-export const AutoCloseAlert: React.FC<AutoCloseAlertProps> = (props) => {
- const { alert } = props;
- const { dismissTime } = alert;
-
- const { t } = useTranslation();
-
- React.useEffect(() => {
- const tag =
- dismissTime === "never"
- ? null
- : typeof dismissTime === "number"
- ? window.setTimeout(props.close, dismissTime)
- : window.setTimeout(props.close, 5000);
- return () => {
- if (tag != null) {
- window.clearTimeout(tag);
- }
- };
- }, [dismissTime, props.close]);
-
- return (
- <Alert
- className="m-3"
- variant={alert.type ?? "primary"}
- onClose={props.close}
- dismissible
- >
- {(() => {
- const { message } = alert;
- if (typeof message === "function") {
- const Message = message;
- return <Message />;
- } else if (typeof message === "object" && message.type === "i18n") {
- return t(message.key);
- } else return alert.message;
- })()}
- </Alert>
- );
-};
-
-// oh what a bad name!
-interface AlertInfoExEx extends AlertInfoEx {
- close: () => void;
-}
-
-const AlertHost: React.FC = () => {
- const [alerts, setAlerts] = React.useState<AlertInfoExEx[]>([]);
-
- // react guarantee that state setters are stable, so we don't need to add it to dependency list
-
- const consume = useCallback((alert: AlertInfoEx): void => {
- const alertEx: AlertInfoExEx = {
- ...alert,
- close: () => {
- setAlerts((oldAlerts) => {
- return without(oldAlerts, alertEx);
- });
- },
- };
- setAlerts((oldAlerts) => {
- return concat(oldAlerts, alertEx);
- });
- }, []);
-
- React.useEffect(() => {
- alertService.registerConsumer(consume);
- return () => {
- alertService.unregisterConsumer(consume);
- };
- }, [consume]);
-
- return (
- <div id={kAlertHostId} className="alert-container">
- {alerts.map((alert) => {
- return (
- <AutoCloseAlert key={alert.id} alert={alert} close={alert.close} />
- );
- })}
- </div>
- );
-};
-
-export default AlertHost;
diff --git a/Timeline/ClientApp/src/app/views/common/alert/alert.sass b/Timeline/ClientApp/src/app/views/common/alert/alert.sass
deleted file mode 100644
index 5b6e65c2..00000000
--- a/Timeline/ClientApp/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/Timeline/ClientApp/src/app/views/common/common.sass b/Timeline/ClientApp/src/app/views/common/common.sass
deleted file mode 100644
index 15d34d7c..00000000
--- a/Timeline/ClientApp/src/app/views/common/common.sass
+++ /dev/null
@@ -1,33 +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
diff --git a/Timeline/ClientApp/src/app/views/home/BoardWithUser.tsx b/Timeline/ClientApp/src/app/views/home/BoardWithUser.tsx
deleted file mode 100644
index dcd39cbe..00000000
--- a/Timeline/ClientApp/src/app/views/home/BoardWithUser.tsx
+++ /dev/null
@@ -1,101 +0,0 @@
-import React from "react";
-import { Row, Col } from "react-bootstrap";
-import { useTranslation } from "react-i18next";
-
-import { UserWithToken } from "@/services/user";
-import { TimelineInfo } from "@/services/timeline";
-import { getHttpTimelineClient } from "@/http/timeline";
-
-import TimelineBoard from "./TimelineBoard";
-import OfflineBoard from "./OfflineBoard";
-
-const BoardWithUser: React.FC<{ user: UserWithToken }> = ({ user }) => {
- const { t } = useTranslation();
-
- const [ownTimelines, setOwnTimelines] = React.useState<
- TimelineInfo[] | "offline" | "loading"
- >("loading");
- const [joinTimelines, setJoinTimelines] = React.useState<
- TimelineInfo[] | "offline" | "loading"
- >("loading");
-
- React.useEffect(() => {
- let subscribe = true;
- if (ownTimelines === "loading") {
- void getHttpTimelineClient()
- .listTimeline({ relate: user.username, relateType: "own" })
- .then(
- (timelines) => {
- if (subscribe) {
- setOwnTimelines(timelines);
- }
- },
- () => {
- setOwnTimelines("offline");
- }
- );
- }
- return () => {
- subscribe = false;
- };
- }, [user, ownTimelines]);
-
- React.useEffect(() => {
- let subscribe = true;
- if (joinTimelines === "loading") {
- void getHttpTimelineClient()
- .listTimeline({ relate: user.username, relateType: "join" })
- .then(
- (timelines) => {
- if (subscribe) {
- setJoinTimelines(timelines);
- }
- },
- () => {
- setJoinTimelines("offline");
- }
- );
- }
- return () => {
- subscribe = false;
- };
- }, [user, joinTimelines]);
-
- return (
- <Row className="my-2 justify-content-center">
- {ownTimelines === "offline" && joinTimelines === "offline" ? (
- <Col className="py-2" sm="8" lg="6">
- <OfflineBoard
- onReload={() => {
- setOwnTimelines("loading");
- setJoinTimelines("loading");
- }}
- />
- </Col>
- ) : (
- <>
- <Col sm="6" lg="5" className="py-2">
- <TimelineBoard
- title={t("home.ownTimeline")}
- timelines={ownTimelines}
- onReload={() => {
- setOwnTimelines("loading");
- }}
- />
- </Col>
- <Col sm="6" lg="5" className="py-2">
- <TimelineBoard
- title={t("home.joinTimeline")}
- timelines={joinTimelines}
- onReload={() => {
- setJoinTimelines("loading");
- }}
- />
- </Col>
- </>
- )}
- </Row>
- );
-};
-
-export default BoardWithUser;
diff --git a/Timeline/ClientApp/src/app/views/home/BoardWithoutUser.tsx b/Timeline/ClientApp/src/app/views/home/BoardWithoutUser.tsx
deleted file mode 100644
index ebfddb50..00000000
--- a/Timeline/ClientApp/src/app/views/home/BoardWithoutUser.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-import React from "react";
-import { Row, Col } from "react-bootstrap";
-
-import { TimelineInfo } from "@/services/timeline";
-import { getHttpTimelineClient } from "@/http/timeline";
-
-import TimelineBoard from "./TimelineBoard";
-import OfflineBoard from "./OfflineBoard";
-
-const BoardWithoutUser: React.FC = () => {
- const [publicTimelines, setPublicTimelines] = React.useState<
- TimelineInfo[] | "offline" | "loading"
- >("loading");
-
- React.useEffect(() => {
- let subscribe = true;
- if (publicTimelines === "loading") {
- void getHttpTimelineClient()
- .listTimeline({ visibility: "Public" })
- .then(
- (timelines) => {
- if (subscribe) {
- setPublicTimelines(timelines);
- }
- },
- () => {
- setPublicTimelines("offline");
- }
- );
- }
- return () => {
- subscribe = false;
- };
- }, [publicTimelines]);
-
- return (
- <Row className="my-2 justify-content-center">
- {publicTimelines === "offline" ? (
- <Col sm="8" lg="6">
- <OfflineBoard
- onReload={() => {
- setPublicTimelines("loading");
- }}
- />
- </Col>
- ) : (
- <Col sm="8" lg="6">
- <TimelineBoard
- timelines={publicTimelines}
- onReload={() => {
- setPublicTimelines("loading");
- }}
- />
- </Col>
- )}
- </Row>
- );
-};
-
-export default BoardWithoutUser;
diff --git a/Timeline/ClientApp/src/app/views/home/OfflineBoard.tsx b/Timeline/ClientApp/src/app/views/home/OfflineBoard.tsx
deleted file mode 100644
index fc05bd74..00000000
--- a/Timeline/ClientApp/src/app/views/home/OfflineBoard.tsx
+++ /dev/null
@@ -1,61 +0,0 @@
-import React from "react";
-import { Link } from "react-router-dom";
-import { Trans } from "react-i18next";
-
-import { getAllCachedTimelineNames } from "@/services/timeline";
-import UserTimelineLogo from "../common/UserTimelineLogo";
-import TimelineLogo from "../common/TimelineLogo";
-
-export interface OfflineBoardProps {
- onReload: () => void;
-}
-
-const OfflineBoard: React.FC<OfflineBoardProps> = ({ onReload }) => {
- const [timelines, setTimelines] = React.useState<string[]>([]);
-
- React.useEffect(() => {
- let subscribe = true;
- void getAllCachedTimelineNames().then((t) => {
- if (subscribe) setTimelines(t);
- });
- return () => {
- subscribe = false;
- };
- });
-
- return (
- <>
- <Trans i18nKey="home.offlinePrompt">
- 0
- <a
- href="#"
- onClick={(e) => {
- onReload();
- e.preventDefault();
- }}
- >
- 1
- </a>
- 2
- </Trans>
- {timelines.map((timeline) => {
- const isPersonal = timeline.startsWith("@");
- const url = isPersonal
- ? `/users/${timeline.slice(1)}`
- : `/timelines/${timeline}`;
- return (
- <div key={timeline} className="timeline-board-item">
- {isPersonal ? (
- <UserTimelineLogo className="icon" />
- ) : (
- <TimelineLogo className="icon" />
- )}
- <Link to={url}>{timeline}</Link>
- </div>
- );
- })}
- </>
- );
-};
-
-export default OfflineBoard;
diff --git a/Timeline/ClientApp/src/app/views/home/TimelineBoard.tsx b/Timeline/ClientApp/src/app/views/home/TimelineBoard.tsx
deleted file mode 100644
index a3d176e1..00000000
--- a/Timeline/ClientApp/src/app/views/home/TimelineBoard.tsx
+++ /dev/null
@@ -1,73 +0,0 @@
-import React from "react";
-import clsx from "clsx";
-import { Link } from "react-router-dom";
-import { Trans } from "react-i18next";
-import { Spinner } from "react-bootstrap";
-
-import { TimelineInfo } from "@/services/timeline";
-import TimelineLogo from "../common/TimelineLogo";
-import UserTimelineLogo from "../common/UserTimelineLogo";
-
-export interface TimelineBoardProps {
- title?: string;
- timelines: TimelineInfo[] | "offline" | "loading";
- onReload: () => void;
- className?: string;
-}
-
-const TimelineBoard: React.FC<TimelineBoardProps> = (props) => {
- const { title, timelines, className } = props;
-
- return (
- <div className={clsx("timeline-board", className)}>
- {title != null && <h3 className="text-center">{title}</h3>}
- {(() => {
- 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">
- <Trans i18nKey="loadFailReload" parent="div">
- 0
- <a
- href="#"
- onClick={(e) => {
- props.onReload();
- e.preventDefault();
- }}
- >
- 1
- </a>
- 2
- </Trans>
- </div>
- );
- } else {
- return timelines.map((timeline) => {
- const { name } = timeline;
- const isPersonal = name.startsWith("@");
- const url = isPersonal
- ? `/users/${timeline.owner.username}`
- : `/timelines/${name}`;
- return (
- <div key={name} className="timeline-board-item">
- {isPersonal ? (
- <UserTimelineLogo className="icon" />
- ) : (
- <TimelineLogo className="icon" />
- )}
- <Link to={url}>{name}</Link>
- </div>
- );
- });
- }
- })()}
- </div>
- );
-};
-
-export default TimelineBoard;
diff --git a/Timeline/ClientApp/src/app/views/home/TimelineCreateDialog.tsx b/Timeline/ClientApp/src/app/views/home/TimelineCreateDialog.tsx
deleted file mode 100644
index d9467719..00000000
--- a/Timeline/ClientApp/src/app/views/home/TimelineCreateDialog.tsx
+++ /dev/null
@@ -1,53 +0,0 @@
-import React from "react";
-import { useHistory } from "react-router";
-
-import { validateTimelineName, timelineService } from "@/services/timeline";
-import OperationDialog from "../common/OperationDialog";
-
-interface TimelineCreateDialogProps {
- open: boolean;
- close: () => void;
-}
-
-const TimelineCreateDialog: React.FC<TimelineCreateDialogProps> = (props) => {
- const history = useHistory();
-
- let nameSaved: string;
-
- return (
- <OperationDialog
- open={props.open}
- close={props.close}
- titleColor="success"
- title="home.createDialog.title"
- inputScheme={[
- {
- type: "text",
- label: "home.createDialog.name",
- helperText: "home.createDialog.nameFormat",
- validator: (name) => {
- if (name.length === 0) {
- return "home.createDialog.noEmpty";
- } else if (name.length > 26) {
- return "home.createDialog.tooLong";
- } else if (!validateTimelineName(name)) {
- return "home.createDialog.badFormat";
- } else {
- return null;
- }
- },
- },
- ]}
- onProcess={([name]) => {
- nameSaved = name as string;
- return timelineService.createTimeline(nameSaved).toPromise();
- }}
- onSuccessAndClose={() => {
- history.push(`timelines/${nameSaved}`);
- }}
- failurePrompt={(e) => `${e as string}`}
- />
- );
-};
-
-export default TimelineCreateDialog;
diff --git a/Timeline/ClientApp/src/app/views/home/home.sass b/Timeline/ClientApp/src/app/views/home/home.sass
deleted file mode 100644
index f5d6ffc3..00000000
--- a/Timeline/ClientApp/src/app/views/home/home.sass
+++ /dev/null
@@ -1,13 +0,0 @@
-.timeline-board-item
- font-size: 1.1em
- @extend .my-2
- .icon
- height: 1.3em
- @extend .mr-2
-
-.timeline-board
- @extend .cru-card
- @extend .d-flex
- @extend .flex-column
- @extend .p-3
- min-height: 200px
diff --git a/Timeline/ClientApp/src/app/views/home/index.tsx b/Timeline/ClientApp/src/app/views/home/index.tsx
deleted file mode 100644
index 760adcea..00000000
--- a/Timeline/ClientApp/src/app/views/home/index.tsx
+++ /dev/null
@@ -1,99 +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 { useUser } from "@/services/user";
-import SearchInput from "../common/SearchInput";
-
-import BoardWithoutUser from "./BoardWithoutUser";
-import BoardWithUser from "./BoardWithUser";
-import TimelineCreateDialog from "./TimelineCreateDialog";
-
-const HomePage: React.FC = () => {
- const history = useHistory();
-
- const { t } = useTranslation();
-
- const user = useUser();
-
- const [navText, setNavText] = React.useState<string>("");
-
- const [dialog, setDialog] = React.useState<"create" | null>(null);
-
- const goto = React.useCallback((): void => {
- if (navText === "") {
- history.push("users/crupest");
- } else if (navText.startsWith("@")) {
- history.push(`users/${navText.slice(1)}`);
- } else {
- history.push(`timelines/${navText}`);
- }
- }, [navText, history]);
-
- return (
- <>
- <Container fluid>
- <Row className="justify-content-center">
- <Col xs={12} sm={10} md={8} lg={6}>
- <SearchInput
- className="justify-content-center"
- value={navText}
- onChange={setNavText}
- onButtonClick={goto}
- buttonText={t("home.go")}
- placeholder="@crupest"
- additionalButton={
- user != null && (
- <Button
- variant="outline-success"
- onClick={() => {
- setDialog("create");
- }}
- >
- {t("home.createButton")}
- </Button>
- )
- }
- />
- </Col>
- </Row>
- {(() => {
- if (user == null) {
- return <BoardWithoutUser />;
- } else {
- return <BoardWithUser user={user} />;
- }
- })()}
- </Container>
- <footer className="text-right">
- <a
- className="mx-3 text-muted"
- href="http://beian.miit.gov.cn/"
- target="_blank"
- rel="noopener noreferrer"
- >
- <small>鄂ICP备18030913号-1</small>
- </a>
- <a
- className="mx-3 text-muted"
- href="http://www.beian.gov.cn/"
- target="_blank"
- rel="noopener noreferrer"
- >
- <small className="white-space-no-wrap">公安备案 42112102000124</small>
- </a>
- </footer>
- {dialog === "create" && (
- <TimelineCreateDialog
- open
- close={() => {
- setDialog(null);
- }}
- />
- )}
- </>
- );
-};
-
-export default HomePage;
diff --git a/Timeline/ClientApp/src/app/views/login/index.tsx b/Timeline/ClientApp/src/app/views/login/index.tsx
deleted file mode 100644
index 265c2172..00000000
--- a/Timeline/ClientApp/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 className="mt-appbar">{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.target.checked);
- }}
- label={t("user.rememberMe")}
- />
- </Form.Group>
- {error ? <p className="text-danger">{t(error)}</p> : null}
- <div className="text-right">
- <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/Timeline/ClientApp/src/app/views/login/login.sass b/Timeline/ClientApp/src/app/views/login/login.sass
deleted file mode 100644
index 0bf385f5..00000000
--- a/Timeline/ClientApp/src/app/views/login/login.sass
+++ /dev/null
@@ -1,2 +0,0 @@
-.login-container
- max-width: 600px
diff --git a/Timeline/ClientApp/src/app/views/settings/index.tsx b/Timeline/ClientApp/src/app/views/settings/index.tsx
deleted file mode 100644
index 964e7442..00000000
--- a/Timeline/ClientApp/src/app/views/settings/index.tsx
+++ /dev/null
@@ -1,209 +0,0 @@
-import React, { useState } from "react";
-import { useHistory } from "react-router";
-import { useTranslation } from "react-i18next";
-import { Form, Container, Row, Col, Button, Modal } from "react-bootstrap";
-
-import { useUser, userService } from "@/services/user";
-import OperationDialog, {
- OperationInputErrorInfo,
-} from "../common/OperationDialog";
-
-interface ChangePasswordDialogProps {
- open: boolean;
- close: () => void;
-}
-
-const ChangePasswordDialog: React.FC<ChangePasswordDialogProps> = (props) => {
- const history = useHistory();
- const { t } = useTranslation();
-
- const [redirect, setRedirect] = useState<boolean>(false);
-
- return (
- <OperationDialog
- open={props.open}
- title={t("settings.dialogChangePassword.title")}
- titleColor="dangerous"
- inputPrompt={t("settings.dialogChangePassword.prompt")}
- inputScheme={[
- {
- type: "text",
- label: t("settings.dialogChangePassword.inputOldPassword"),
- password: true,
- validator: (v) =>
- v === ""
- ? "settings.dialogChangePassword.errorEmptyOldPassword"
- : null,
- },
- {
- type: "text",
- label: t("settings.dialogChangePassword.inputNewPassword"),
- password: true,
- validator: (v, values) => {
- const error: OperationInputErrorInfo = {};
- error[1] =
- v === ""
- ? "settings.dialogChangePassword.errorEmptyNewPassword"
- : null;
- if (v === values[2]) {
- error[2] = null;
- } else {
- if (values[2] !== "") {
- error[2] = "settings.dialogChangePassword.errorRetypeNotMatch";
- }
- }
- return error;
- },
- },
- {
- type: "text",
- label: t("settings.dialogChangePassword.inputRetypeNewPassword"),
- password: true,
- validator: (v, values) =>
- v !== values[1]
- ? "settings.dialogChangePassword.errorRetypeNotMatch"
- : null,
- },
- ]}
- onProcess={async ([oldPassword, newPassword]) => {
- await userService
- .changePassword(oldPassword as string, newPassword as string)
- .toPromise();
- await userService.logout();
- setRedirect(true);
- }}
- close={() => {
- props.close();
- if (redirect) {
- history.push("/login");
- }
- }}
- />
- );
-};
-
-const ConfirmLogoutDialog: React.FC<{
- toggle: () => void;
- onConfirm: () => void;
-}> = ({ toggle, onConfirm }) => {
- const { t } = useTranslation();
-
- return (
- <Modal show centered onHide={toggle}>
- <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={toggle}>
- {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" | "logout">(
- null
- );
-
- const language = i18n.language.slice(0, 2);
-
- return (
- <Container fluid>
- {user ? (
- <>
- <Row className="border-bottom p-3 cursor-pointer">
- <Col xs="12">
- <h5
- onClick={() => {
- history.push(`/users/${user.username}`);
- }}
- >
- {t("settings.gotoSelf")}
- </h5>
- </Col>
- </Row>
- <Row className="border-bottom p-3 cursor-pointer">
- <Col xs="12">
- <h5
- className="text-danger"
- onClick={() => setDialog("changepassword")}
- >
- {t("settings.changePassword")}
- </h5>
- </Col>
- </Row>
- <Row className="border-bottom p-3 cursor-pointer">
- <Col xs="12">
- <h5
- className="text-danger"
- onClick={() => {
- setDialog("logout");
- }}
- >
- {t("settings.logout")}
- </h5>
- </Col>
- </Row>
- </>
- ) : null}
- <Row className="align-items-center border-bottom p-3">
- <Col xs="12" sm="auto">
- <h5>{t("settings.languagePrimary")}</h5>
- <p>{t("settings.languageSecondary")}</p>
- </Col>
- <Col xs="auto" className="ml-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>
- {(() => {
- switch (dialog) {
- case "changepassword":
- return (
- <ChangePasswordDialog
- open
- close={() => {
- setDialog(null);
- }}
- />
- );
- case "logout":
- return (
- <ConfirmLogoutDialog
- toggle={() => setDialog(null)}
- onConfirm={() => {
- void userService.logout().then(() => {
- history.push("/");
- });
- }}
- />
- );
- default:
- return null;
- }
- })()}
- </Container>
- );
-};
-
-export default SettingsPage;
diff --git a/Timeline/ClientApp/src/app/views/timeline-common/CollapseButton.tsx b/Timeline/ClientApp/src/app/views/timeline-common/CollapseButton.tsx
deleted file mode 100644
index 3c52150f..00000000
--- a/Timeline/ClientApp/src/app/views/timeline-common/CollapseButton.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-import React from "react";
-import clsx from "clsx";
-import Svg from "react-inlinesvg";
-import arrowsAngleContractIcon from "bootstrap-icons/icons/arrows-angle-contract.svg";
-import arrowsAngleExpandIcon from "bootstrap-icons/icons/arrows-angle-expand.svg";
-
-const CollapseButton: React.FC<{
- collapse: boolean;
- onClick: () => void;
- className?: string;
- style?: React.CSSProperties;
-}> = ({ collapse, onClick, className, style }) => {
- return (
- <Svg
- src={collapse ? arrowsAngleExpandIcon : arrowsAngleContractIcon}
- onClick={onClick}
- className={clsx("text-primary icon-button", className)}
- style={style}
- />
- );
-};
-
-export default CollapseButton;
diff --git a/Timeline/ClientApp/src/app/views/timeline-common/InfoCardTemplate.tsx b/Timeline/ClientApp/src/app/views/timeline-common/InfoCardTemplate.tsx
deleted file mode 100644
index a8de20aa..00000000
--- a/Timeline/ClientApp/src/app/views/timeline-common/InfoCardTemplate.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-import React from "react";
-import clsx from "clsx";
-
-import { TimelineCardComponentProps } from "../timeline-common/TimelinePageTemplateUI";
-import SyncStatusBadge from "../timeline-common/SyncStatusBadge";
-import CollapseButton from "../timeline-common/CollapseButton";
-
-const InfoCardTemplate: React.FC<
- Pick<
- TimelineCardComponentProps<"">,
- "collapse" | "toggleCollapse" | "syncStatus" | "className"
- > & { children: React.ReactElement[] }
-> = ({ collapse, toggleCollapse, syncStatus, className, children }) => {
- return (
- <div className={clsx("cru-card p-2 clearfix", className)}>
- <div className="float-right d-flex align-items-center">
- <SyncStatusBadge status={syncStatus} className="mr-2" />
- <CollapseButton collapse={collapse} onClick={toggleCollapse} />
- </div>
-
- <div style={{ display: collapse ? "none" : "block" }}>{children}</div>
- </div>
- );
-};
-
-export default InfoCardTemplate;
diff --git a/Timeline/ClientApp/src/app/views/timeline-common/SyncStatusBadge.tsx b/Timeline/ClientApp/src/app/views/timeline-common/SyncStatusBadge.tsx
deleted file mode 100644
index e67cfb43..00000000
--- a/Timeline/ClientApp/src/app/views/timeline-common/SyncStatusBadge.tsx
+++ /dev/null
@@ -1,58 +0,0 @@
-import React from "react";
-import clsx from "clsx";
-import { useTranslation } from "react-i18next";
-
-import { UiLogicError } from "@/common";
-
-export type TimelineSyncStatus = "syncing" | "synced" | "offline";
-
-const SyncStatusBadge: React.FC<{
- status: TimelineSyncStatus;
- style?: React.CSSProperties;
- className?: string;
-}> = ({ status, style, className }) => {
- const { t } = useTranslation();
-
- return (
- <div style={style} className={clsx("timeline-sync-state-badge", className)}>
- {(() => {
- switch (status) {
- case "syncing": {
- return (
- <>
- <span className="timeline-sync-state-badge-pin bg-warning" />
- <span className="text-warning">
- {t("timeline.postSyncState.syncing")}
- </span>
- </>
- );
- }
- case "synced": {
- return (
- <>
- <span className="timeline-sync-state-badge-pin bg-success" />
- <span className="text-success">
- {t("timeline.postSyncState.synced")}
- </span>
- </>
- );
- }
- case "offline": {
- return (
- <>
- <span className="timeline-sync-state-badge-pin bg-danger" />
- <span className="text-danger">
- {t("timeline.postSyncState.offline")}
- </span>
- </>
- );
- }
- default:
- throw new UiLogicError("Unknown sync state.");
- }
- })()}
- </div>
- );
-};
-
-export default SyncStatusBadge;
diff --git a/Timeline/ClientApp/src/app/views/timeline-common/Timeline.tsx b/Timeline/ClientApp/src/app/views/timeline-common/Timeline.tsx
deleted file mode 100644
index fd051d45..00000000
--- a/Timeline/ClientApp/src/app/views/timeline-common/Timeline.tsx
+++ /dev/null
@@ -1,84 +0,0 @@
-import React from "react";
-import clsx from "clsx";
-
-import { TimelinePostInfo } from "@/services/timeline";
-
-import TimelineItem from "./TimelineItem";
-
-export interface TimelinePostInfoEx extends TimelinePostInfo {
- deletable: boolean;
-}
-
-export type TimelineDeleteCallback = (index: number, id: number) => void;
-
-export interface TimelineProps {
- className?: string;
- posts: TimelinePostInfoEx[];
- onDelete: TimelineDeleteCallback;
- onResize?: () => void;
- containerRef?: React.Ref<HTMLDivElement>;
-}
-
-const Timeline: React.FC<TimelineProps> = (props) => {
- const { posts, onDelete, onResize } = props;
-
- const [indexShowDeleteButton, setIndexShowDeleteButton] = React.useState<
- number
- >(-1);
-
- const onItemClick = React.useCallback(() => {
- setIndexShowDeleteButton(-1);
- }, []);
-
- const onToggleDelete = React.useMemo(() => {
- return posts.map((post, i) => {
- return post.deletable
- ? () => {
- setIndexShowDeleteButton((oldIndexShowDeleteButton) => {
- return oldIndexShowDeleteButton !== i ? i : -1;
- });
- }
- : undefined;
- });
- }, [posts]);
-
- const onItemDelete = React.useMemo(() => {
- return posts.map((post, i) => {
- return () => {
- onDelete(i, post.id);
- };
- });
- }, [posts, onDelete]);
-
- return (
- <div ref={props.containerRef} className={clsx("timeline", props.className)}>
- {(() => {
- const length = posts.length;
- return posts.map((post, i) => {
- const toggleMore = onToggleDelete[i];
-
- return (
- <TimelineItem
- post={post}
- key={post.id}
- current={length - 1 === i}
- more={
- toggleMore
- ? {
- isOpen: indexShowDeleteButton === i,
- toggle: toggleMore,
- onDelete: onItemDelete[i],
- }
- : undefined
- }
- onClick={onItemClick}
- onResize={onResize}
- />
- );
- });
- })()}
- </div>
- );
-};
-
-export default Timeline;
diff --git a/Timeline/ClientApp/src/app/views/timeline-common/TimelineItem.tsx b/Timeline/ClientApp/src/app/views/timeline-common/TimelineItem.tsx
deleted file mode 100644
index 327b6833..00000000
--- a/Timeline/ClientApp/src/app/views/timeline-common/TimelineItem.tsx
+++ /dev/null
@@ -1,172 +0,0 @@
-import React from "react";
-import clsx from "clsx";
-import { Link } from "react-router-dom";
-import { useTranslation } from "react-i18next";
-import Svg from "react-inlinesvg";
-import chevronDownIcon from "bootstrap-icons/icons/chevron-down.svg";
-import trashIcon from "bootstrap-icons/icons/trash.svg";
-import { Modal, Button } from "react-bootstrap";
-
-import { useAvatar } from "@/services/user";
-import { TimelinePostInfo } from "@/services/timeline";
-
-import BlobImage from "../common/BlobImage";
-
-const TimelinePostDeleteConfirmDialog: React.FC<{
- toggle: () => void;
- onConfirm: () => void;
-}> = ({ toggle, onConfirm }) => {
- const { t } = useTranslation();
-
- return (
- <Modal toggle={toggle} isOpen 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={toggle}>
- {t("operationDialog.cancel")}
- </Button>
- <Button
- variant="danger"
- onClick={() => {
- onConfirm();
- toggle();
- }}
- >
- {t("operationDialog.confirm")}
- </Button>
- </Modal.Footer>
- </Modal>
- );
-};
-
-export interface TimelineItemProps {
- post: TimelinePostInfo;
- current?: boolean;
- more?: {
- isOpen: boolean;
- toggle: () => void;
- onDelete: () => void;
- };
- onClick?: () => void;
- onResize?: () => void;
- className?: string;
- style?: React.CSSProperties;
-}
-
-const TimelineItem: React.FC<TimelineItemProps> = (props) => {
- const { i18n } = useTranslation();
-
- const current = props.current === true;
-
- const { more, onResize } = props;
-
- const avatar = useAvatar(props.post.author.username);
-
- const [deleteDialog, setDeleteDialog] = React.useState<boolean>(false);
- const toggleDeleteDialog = React.useCallback(
- () => setDeleteDialog((old) => !old),
- []
- );
-
- return (
- <div
- className={clsx(
- "timeline-item position-relative",
- current && "current",
- props.className
- )}
- onClick={props.onClick}
- style={props.style}
- >
- <div className="timeline-line-area-container">
- <div className="timeline-line-area">
- <div className="timeline-line-segment start"></div>
- <div className="timeline-line-node-container">
- <div className="timeline-line-node"></div>
- </div>
- <div className="timeline-line-segment end"></div>
- {current && <div className="timeline-line-segment current-end" />}
- </div>
- </div>
- <div className="timeline-content-area">
- <div>
- <span className="mr-2">
- <span className="text-primary white-space-no-wrap mr-2">
- {props.post.time.toLocaleString(i18n.languages)}
- </span>
- <small className="text-dark">{props.post.author.nickname}</small>
- </span>
- {more != null ? (
- <Svg
- src={chevronDownIcon}
- className="text-info icon-button"
- onClick={(e: Event) => {
- more.toggle();
- e.stopPropagation();
- }}
- />
- ) : null}
- </div>
- <div className="timeline-content">
- <Link
- className="float-left m-2"
- to={"/users/" + props.post.author.username}
- >
- <BlobImage
- onLoad={onResize}
- blob={avatar}
- className="avatar rounded"
- />
- </Link>
- {(() => {
- const { content } = props.post;
- if (content.type === "text") {
- return content.text;
- } else {
- return (
- <BlobImage
- onLoad={onResize}
- blob={content.data}
- className="timeline-content-image"
- />
- );
- }
- })()}
- </div>
- </div>
- {more != null && more.isOpen ? (
- <>
- <div
- className="position-absolute position-lt w-100 h-100 mask d-flex justify-content-center align-items-center"
- onClick={more.toggle}
- >
- <Svg
- src={trashIcon}
- className="text-danger icon-button large"
- onClick={(e: Event) => {
- toggleDeleteDialog();
- e.stopPropagation();
- }}
- />
- </div>
- {deleteDialog ? (
- <TimelinePostDeleteConfirmDialog
- toggle={() => {
- toggleDeleteDialog();
- more.toggle();
- }}
- onConfirm={more.onDelete}
- />
- ) : null}
- </>
- ) : null}
- </div>
- );
-};
-
-export default TimelineItem;
diff --git a/Timeline/ClientApp/src/app/views/timeline-common/TimelineMember.tsx b/Timeline/ClientApp/src/app/views/timeline-common/TimelineMember.tsx
deleted file mode 100644
index 67a8543a..00000000
--- a/Timeline/ClientApp/src/app/views/timeline-common/TimelineMember.tsx
+++ /dev/null
@@ -1,211 +0,0 @@
-import React, { useState } from "react";
-import { useTranslation } from "react-i18next";
-import { Container, ListGroup, Modal, Row, Col, Button } from "react-bootstrap";
-
-import { User, useAvatar } from "@/services/user";
-
-import SearchInput from "../common/SearchInput";
-import BlobImage from "../common/BlobImage";
-
-const TimelineMemberItem: React.FC<{
- user: User;
- owner: boolean;
- onRemove?: (username: string) => void;
-}> = ({ user, owner, onRemove }) => {
- const { t } = useTranslation();
-
- const avatar = useAvatar(user.username);
-
- return (
- <ListGroup.Item className="container">
- <Row>
- <Col xs="auto">
- <BlobImage blob={avatar} className="avatar small" />
- </Col>
- <Col>
- <Row>{user.nickname}</Row>
- <Row>
- <small>{"@" + user.username}</small>
- </Row>
- </Col>
- {(() => {
- if (owner) {
- return null;
- }
- if (onRemove == null) {
- return null;
- }
- return (
- <Button
- className="align-self-center"
- variant="danger"
- onClick={() => {
- onRemove(user.username);
- }}
- >
- {t("timeline.member.remove")}
- </Button>
- );
- })()}
- </Row>
- </ListGroup.Item>
- );
-};
-
-export interface TimelineMemberCallbacks {
- onCheckUser: (username: string) => Promise<User | null>;
- onAddUser: (user: User) => Promise<void>;
- onRemoveUser: (username: string) => void;
-}
-
-export interface TimelineMemberProps {
- members: User[];
- edit: TimelineMemberCallbacks | null | undefined;
-}
-
-const TimelineMember: React.FC<TimelineMemberProps> = (props) => {
- const { t } = useTranslation();
-
- const [userSearchText, setUserSearchText] = useState<string>("");
- const [userSearchState, setUserSearchState] = useState<
- | {
- type: "user";
- data: User;
- }
- | { type: "error"; data: string }
- | { type: "loading" }
- | { type: "init" }
- >({ type: "init" });
-
- const userSearchAvatar = useAvatar(
- userSearchState.type === "user" ? userSearchState.data.username : undefined
- );
-
- const members = props.members;
-
- return (
- <Container className="px-4">
- <ListGroup className="my-3">
- {members.map((member, index) => (
- <TimelineMemberItem
- key={member.username}
- user={member}
- owner={index === 0}
- onRemove={props.edit?.onRemoveUser}
- />
- ))}
- </ListGroup>
- {(() => {
- const edit = props.edit;
- if (edit != null) {
- return (
- <>
- <SearchInput
- value={userSearchText}
- onChange={(v) => {
- setUserSearchText(v);
- }}
- loading={userSearchState.type === "loading"}
- onButtonClick={() => {
- if (userSearchText === "") {
- setUserSearchState({
- type: "error",
- data: "login.emptyUsername",
- });
- return;
- }
-
- setUserSearchState({ type: "loading" });
- edit.onCheckUser(userSearchText).then(
- (u) => {
- if (u == null) {
- setUserSearchState({
- type: "error",
- data: "timeline.userNotExist",
- });
- } else {
- setUserSearchState({ type: "user", data: u });
- }
- },
- (e) => {
- setUserSearchState({
- type: "error",
- data: `${e as string}`,
- });
- }
- );
- }}
- />
- {(() => {
- if (userSearchState.type === "user") {
- const u = userSearchState.data;
- const addable =
- members.findIndex((m) => m.username === u.username) === -1;
- return (
- <>
- {!addable ? (
- <p>{t("timeline.member.alreadyMember")}</p>
- ) : null}
- <Container className="mb-3">
- <Row>
- <Col className="col-auto">
- <BlobImage
- blob={userSearchAvatar}
- className="avatar small"
- />
- </Col>
- <Col>
- <Row>{u.nickname}</Row>
- <Row>
- <small>{"@" + u.username}</small>
- </Row>
- </Col>
- <Button
- variant="primary"
- className="align-self-center"
- disabled={!addable}
- onClick={() => {
- void edit.onAddUser(u).then((_) => {
- setUserSearchText("");
- setUserSearchState({ type: "init" });
- });
- }}
- >
- {t("timeline.member.add")}
- </Button>
- </Row>
- </Container>
- </>
- );
- } else if (userSearchState.type === "error") {
- return (
- <p className="text-danger">{t(userSearchState.data)}</p>
- );
- }
- })()}
- </>
- );
- } else {
- return 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/Timeline/ClientApp/src/app/views/timeline-common/TimelinePageTemplate.tsx b/Timeline/ClientApp/src/app/views/timeline-common/TimelinePageTemplate.tsx
deleted file mode 100644
index d5c91622..00000000
--- a/Timeline/ClientApp/src/app/views/timeline-common/TimelinePageTemplate.tsx
+++ /dev/null
@@ -1,185 +0,0 @@
-import React from "react";
-import { useTranslation } from "react-i18next";
-import { of } from "rxjs";
-import { catchError } from "rxjs/operators";
-
-import { UiLogicError } from "@/common";
-import { pushAlert } from "@/services/alert";
-import { useUser, userInfoService, UserNotExistError } from "@/services/user";
-import {
- timelineService,
- usePostList,
- useTimelineInfo,
-} from "@/services/timeline";
-
-import { TimelineDeleteCallback } from "./Timeline";
-import { TimelineMemberDialog } from "./TimelineMember";
-import TimelinePropertyChangeDialog from "./TimelinePropertyChangeDialog";
-import { TimelinePageTemplateUIProps } from "./TimelinePageTemplateUI";
-import { TimelinePostSendCallback } from "./TimelinePostEdit";
-
-export interface TimelinePageTemplateProps<TManageItem> {
- name: string;
- onManage: (item: TManageItem) => void;
- UiComponent: React.ComponentType<
- Omit<TimelinePageTemplateUIProps<TManageItem>, "CardComponent">
- >;
- notFoundI18nKey: string;
-}
-
-export default function TimelinePageTemplate<TManageItem>(
- props: TimelinePageTemplateProps<TManageItem>
-): React.ReactElement | null {
- const { t } = useTranslation();
-
- const { name } = props;
-
- const service = timelineService;
-
- const user = useUser();
-
- const [dialog, setDialog] = React.useState<null | "property" | "member">(
- null
- );
-
- const timelineState = useTimelineInfo(name);
-
- const timeline = timelineState?.timeline;
-
- const postListState = usePostList(name);
-
- const error: string | undefined = (() => {
- if (timelineState != null) {
- const { type, timeline } = timelineState;
- if (type === "offline" && timeline == null) return "Network Error";
- if (type === "synced" && timeline == null)
- return t(props.notFoundI18nKey);
- }
- return undefined;
- })();
-
- const closeDialog = React.useCallback((): void => {
- setDialog(null);
- }, []);
-
- let dialogElement: React.ReactElement | undefined;
-
- if (dialog === "property") {
- if (timeline == null) {
- throw new UiLogicError(
- "Timeline is null but attempt to open change property dialog."
- );
- }
-
- dialogElement = (
- <TimelinePropertyChangeDialog
- open
- close={closeDialog}
- oldInfo={{
- visibility: timeline.visibility,
- description: timeline.description,
- }}
- onProcess={(req) => {
- return service.changeTimelineProperty(name, req).toPromise().then();
- }}
- />
- );
- } else if (dialog === "member") {
- if (timeline == null) {
- throw new UiLogicError(
- "Timeline is null but attempt to open change property dialog."
- );
- }
-
- dialogElement = (
- <TimelineMemberDialog
- open
- onClose={closeDialog}
- members={[timeline.owner, ...timeline.members]}
- edit={
- service.hasManagePermission(user, timeline)
- ? {
- onCheckUser: (u) => {
- return userInfoService
- .getUserInfo(u)
- .pipe(
- catchError((e) => {
- if (e instanceof UserNotExistError) {
- return of(null);
- } else {
- throw e;
- }
- })
- )
- .toPromise();
- },
- onAddUser: (u) => {
- return service.addMember(name, u.username).toPromise().then();
- },
- onRemoveUser: (u) => {
- service.removeMember(name, u);
- },
- }
- : null
- }
- />
- );
- }
-
- const { UiComponent } = props;
-
- const onDelete: TimelineDeleteCallback = React.useCallback(
- (index, id) => {
- service.deletePost(name, id).subscribe(null, () => {
- pushAlert({
- type: "danger",
- message: t("timeline.deletePostFailed"),
- });
- });
- },
- [service, name, t]
- );
-
- const onPost: TimelinePostSendCallback = React.useCallback(
- (req) => {
- return service.createPost(name, req).toPromise().then();
- },
- [service, name]
- );
-
- const onManageProp = props.onManage;
-
- const onManage = React.useCallback(
- (item: "property" | TManageItem) => {
- if (item === "property") {
- setDialog(item);
- } else {
- onManageProp(item);
- }
- },
- [onManageProp]
- );
-
- return (
- <>
- <UiComponent
- error={error}
- timeline={timeline ?? undefined}
- postListState={postListState}
- onDelete={onDelete}
- onPost={
- timeline != null && service.hasPostPermission(user, timeline)
- ? onPost
- : undefined
- }
- onManage={
- timeline != null && service.hasManagePermission(user, timeline)
- ? onManage
- : undefined
- }
- onMember={() => setDialog("member")}
- />
- {dialogElement}
- </>
- );
-}
diff --git a/Timeline/ClientApp/src/app/views/timeline-common/TimelinePageTemplateUI.tsx b/Timeline/ClientApp/src/app/views/timeline-common/TimelinePageTemplateUI.tsx
deleted file mode 100644
index 6c2c43c1..00000000
--- a/Timeline/ClientApp/src/app/views/timeline-common/TimelinePageTemplateUI.tsx
+++ /dev/null
@@ -1,243 +0,0 @@
-import React from "react";
-import { useTranslation } from "react-i18next";
-import { fromEvent } from "rxjs";
-import { Spinner } from "react-bootstrap";
-
-import { getAlertHost } from "@/services/alert";
-import { useEventEmiiter, UiLogicError } from "@/common";
-import {
- TimelineInfo,
- TimelinePostsWithSyncState,
- timelineService,
-} from "@/services/timeline";
-import { userService } from "@/services/user";
-
-import Timeline, {
- TimelinePostInfoEx,
- TimelineDeleteCallback,
-} from "./Timeline";
-import TimelineTop from "./TimelineTop";
-import TimelinePostEdit, { TimelinePostSendCallback } from "./TimelinePostEdit";
-import { TimelineSyncStatus } from "./SyncStatusBadge";
-
-export interface TimelineCardComponentProps<TManageItems> {
- timeline: TimelineInfo;
- onManage?: (item: TManageItems | "property") => void;
- onMember: () => void;
- className?: string;
- collapse: boolean;
- syncStatus: TimelineSyncStatus;
- toggleCollapse: () => void;
-}
-
-export interface TimelinePageTemplateUIProps<TManageItems> {
- timeline?: TimelineInfo;
- postListState?: TimelinePostsWithSyncState;
- CardComponent: React.ComponentType<TimelineCardComponentProps<TManageItems>>;
- onMember: () => void;
- onManage?: (item: TManageItems | "property") => void;
- onPost?: TimelinePostSendCallback;
- onDelete: TimelineDeleteCallback;
- error?: string;
-}
-
-export default function TimelinePageTemplateUI<TManageItems>(
- props: TimelinePageTemplateUIProps<TManageItems>
-): React.ReactElement | null {
- const { timeline, postListState } = props;
-
- const { t } = useTranslation();
-
- const bottomSpaceRef = React.useRef<HTMLDivElement | null>(null);
-
- const onPostEditHeightChange = React.useCallback((height: number): void => {
- const { current: bottomSpaceDiv } = bottomSpaceRef;
- if (bottomSpaceDiv != null) {
- bottomSpaceDiv.style.height = `${height}px`;
- }
- 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 timelineRef = React.useRef<HTMLDivElement | null>(null);
-
- const [getResizeEvent, triggerResizeEvent] = useEventEmiiter();
-
- React.useEffect(() => {
- const { current: timelineElement } = timelineRef;
- if (timelineElement != null) {
- let loadingScrollToBottom = true;
- let pinBottom = false;
-
- const isAtBottom = (): boolean =>
- window.innerHeight + window.scrollY + 10 >= document.body.scrollHeight;
-
- const disableLoadingScrollToBottom = (): void => {
- loadingScrollToBottom = false;
- if (isAtBottom()) pinBottom = true;
- };
-
- const checkAndScrollToBottom = (): void => {
- if (loadingScrollToBottom || pinBottom) {
- window.scrollTo(0, document.body.scrollHeight);
- }
- };
-
- const subscriptions = [
- fromEvent(timelineElement, "wheel").subscribe(
- disableLoadingScrollToBottom
- ),
- fromEvent(timelineElement, "pointerdown").subscribe(
- disableLoadingScrollToBottom
- ),
- fromEvent(timelineElement, "keydown").subscribe(
- disableLoadingScrollToBottom
- ),
- fromEvent(window, "scroll").subscribe(() => {
- if (loadingScrollToBottom) return;
-
- if (isAtBottom()) {
- pinBottom = true;
- } else {
- pinBottom = false;
- }
- }),
- fromEvent(window, "resize").subscribe(checkAndScrollToBottom),
- getResizeEvent().subscribe(checkAndScrollToBottom),
- ];
-
- return () => {
- subscriptions.forEach((s) => s.unsubscribe());
- };
- }
- }, [getResizeEvent, triggerResizeEvent, timeline, postListState]);
-
- const genCardCollapseLocalStorageKey = (uniqueId: string): string =>
- `timeline.${uniqueId}.cardCollapse`;
-
- const cardCollapseLocalStorageKey =
- timeline != null ? genCardCollapseLocalStorageKey(timeline.uniqueId) : null;
-
- const [cardCollapse, setCardCollapse] = React.useState<boolean>(true);
- React.useEffect(() => {
- if (cardCollapseLocalStorageKey != null) {
- const savedCollapse =
- window.localStorage.getItem(cardCollapseLocalStorageKey) === "true";
- setCardCollapse(savedCollapse);
- }
- }, [cardCollapseLocalStorageKey]);
-
- const toggleCardCollapse = (): void => {
- const newState = !cardCollapse;
- setCardCollapse(newState);
- if (timeline != null) {
- window.localStorage.setItem(
- genCardCollapseLocalStorageKey(timeline.uniqueId),
- newState.toString()
- );
- }
- };
-
- let body: React.ReactElement;
-
- if (props.error != null) {
- body = <p className="text-danger">{t(props.error)}</p>;
- } else {
- if (timeline != null) {
- let timelineBody: React.ReactElement;
- if (postListState != null) {
- if (postListState.type === "notexist") {
- throw new UiLogicError(
- "Timeline is not null but post list state is notexist."
- );
- }
- if (postListState.type === "forbid") {
- timelineBody = (
- <p className="text-danger">{t("timeline.messageCantSee")}</p>
- );
- } else {
- const posts: TimelinePostInfoEx[] = postListState.posts.map(
- (post) => ({
- ...post,
- deletable: timelineService.hasModifyPostPermission(
- userService.currentUser,
- timeline,
- post
- ),
- })
- );
-
- timelineBody = (
- <Timeline
- containerRef={timelineRef}
- posts={posts}
- onDelete={props.onDelete}
- onResize={triggerResizeEvent}
- />
- );
- if (props.onPost != null) {
- timelineBody = (
- <>
- {timelineBody}
- <div ref={bottomSpaceRef} className="flex-fix-length" />
- <TimelinePostEdit
- className="fixed-bottom"
- onPost={props.onPost}
- onHeightChange={onPostEditHeightChange}
- timelineUniqueId={timeline.uniqueId}
- />
- </>
- );
- }
- }
- } else {
- timelineBody = (
- <div className="full-viewport-center-child">
- <Spinner variant="primary" animation="grow" />
- </div>
- );
- }
-
- const { CardComponent } = props;
- const syncStatus: TimelineSyncStatus =
- postListState == null || postListState.syncing
- ? "syncing"
- : postListState.type === "synced"
- ? "synced"
- : "offline";
-
- body = (
- <>
- <CardComponent
- className="timeline-template-card"
- timeline={timeline}
- onManage={props.onManage}
- onMember={props.onMember}
- syncStatus={syncStatus}
- collapse={cardCollapse}
- toggleCollapse={toggleCardCollapse}
- />
- <TimelineTop height="56px" />
- {timelineBody}
- </>
- );
- } else {
- body = (
- <div className="full-viewport-center-child">
- <Spinner variant="primary" animation="grow" />
- </div>
- );
- }
- }
-
- return body;
-}
diff --git a/Timeline/ClientApp/src/app/views/timeline-common/TimelinePostEdit.tsx b/Timeline/ClientApp/src/app/views/timeline-common/TimelinePostEdit.tsx
deleted file mode 100644
index dfa2f879..00000000
--- a/Timeline/ClientApp/src/app/views/timeline-common/TimelinePostEdit.tsx
+++ /dev/null
@@ -1,241 +0,0 @@
-import React from "react";
-import clsx from "clsx";
-import { useTranslation } from "react-i18next";
-import Svg from "react-inlinesvg";
-import { Button, Spinner, Row, Col, Form } from "react-bootstrap";
-import textIcon from "bootstrap-icons/icons/card-text.svg";
-import imageIcon from "bootstrap-icons/icons/image.svg";
-
-import { UiLogicError } from "@/common";
-
-import { pushAlert } from "@/services/alert";
-import { TimelineCreatePostRequest } from "@/services/timeline";
-
-interface TimelinePostEditImageProps {
- onSelect: (blob: Blob | null) => void;
-}
-
-const TimelinePostEditImage: React.FC<TimelinePostEditImageProps> = (props) => {
- const { onSelect } = props;
- const { t } = useTranslation();
-
- const [file, setFile] = React.useState<File | null>(null);
- const [fileUrl, setFileUrl] = React.useState<string | null>(null);
- const [error, setError] = React.useState<string | null>(null);
-
- React.useEffect(() => {
- if (file != null) {
- const url = URL.createObjectURL(file);
- setFileUrl(url);
- return () => {
- URL.revokeObjectURL(url);
- };
- }
- }, [file]);
-
- const onInputChange: React.ChangeEventHandler<HTMLInputElement> = React.useCallback(
- (e) => {
- const files = e.target.files;
- if (files == null || files.length === 0) {
- setFile(null);
- setFileUrl(null);
- } else {
- setFile(files[0]);
- }
- onSelect(null);
- setError(null);
- },
- [onSelect]
- );
-
- const onImgLoad = React.useCallback(() => {
- onSelect(file);
- }, [onSelect, file]);
-
- const onImgError = React.useCallback(() => {
- setError("loadImageError");
- }, []);
-
- return (
- <>
- <Form.File
- label={t("chooseImage")}
- onChange={onInputChange}
- accept="image/*"
- className="mx-3 my-1 d-inline-block"
- />
- {fileUrl && error == null && (
- <img
- src={fileUrl}
- className="timeline-post-edit-image"
- onLoad={onImgLoad}
- onError={onImgError}
- />
- )}
- {error != null && <div className="text-danger">{t(error)}</div>}
- </>
- );
-};
-
-export type TimelinePostSendCallback = (
- content: TimelineCreatePostRequest
-) => Promise<void>;
-
-export interface TimelinePostEditProps {
- className?: string;
- onPost: TimelinePostSendCallback;
- onHeightChange?: (height: number) => void;
- timelineUniqueId: string;
-}
-
-const TimelinePostEdit: React.FC<TimelinePostEditProps> = (props) => {
- const { onPost } = props;
-
- const { t } = useTranslation();
-
- const [state, setState] = React.useState<"input" | "process">("input");
- const [kind, setKind] = React.useState<"text" | "image">("text");
- const [text, setText] = React.useState<string>("");
- const [imageBlob, setImageBlob] = React.useState<Blob | null>(null);
-
- const draftLocalStorageKey = `timeline.${props.timelineUniqueId}.postDraft`;
-
- React.useEffect(() => {
- setText(window.localStorage.getItem(draftLocalStorageKey) ?? "");
- }, [draftLocalStorageKey]);
-
- const canSend = kind === "text" || (kind === "image" && imageBlob != null);
-
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- const containerRef = React.useRef<HTMLDivElement>(null!);
-
- const notifyHeightChange = (): void => {
- if (props.onHeightChange) {
- props.onHeightChange(containerRef.current.clientHeight);
- }
- };
-
- React.useEffect(() => {
- if (props.onHeightChange) {
- props.onHeightChange(containerRef.current.clientHeight);
- }
- return () => {
- if (props.onHeightChange) {
- props.onHeightChange(0);
- }
- };
- });
-
- const toggleKind = React.useCallback(() => {
- setKind((oldKind) => (oldKind === "text" ? "image" : "text"));
- setImageBlob(null);
- }, []);
-
- const onSend = React.useCallback(() => {
- setState("process");
-
- const req: TimelineCreatePostRequest = (() => {
- switch (kind) {
- case "text":
- return {
- content: {
- type: "text",
- text: text,
- },
- } as TimelineCreatePostRequest;
- case "image":
- if (imageBlob == null) {
- throw new UiLogicError(
- "Content type is image but image blob is null."
- );
- }
- return {
- content: {
- type: "image",
- data: imageBlob,
- },
- } as TimelineCreatePostRequest;
- default:
- throw new UiLogicError("Unknown content type.");
- }
- })();
-
- onPost(req).then(
- (_) => {
- if (kind === "text") {
- setText("");
- window.localStorage.removeItem(draftLocalStorageKey);
- }
- setState("input");
- setKind("text");
- },
- (_) => {
- pushAlert({
- type: "danger",
- message: t("timeline.sendPostFailed"),
- });
- setState("input");
- }
- );
- }, [onPost, kind, text, imageBlob, t, draftLocalStorageKey]);
-
- const onImageSelect = React.useCallback((blob: Blob | null) => {
- setImageBlob(blob);
- }, []);
-
- return (
- <div
- ref={containerRef}
- className={clsx("container-fluid bg-light", props.className)}
- >
- <Row>
- <Col className="px-1 py-1">
- {kind === "text" ? (
- <Form.Control
- as="textarea"
- className="w-100 h-100 timeline-post-edit"
- value={text}
- disabled={state === "process"}
- onChange={(event: React.ChangeEvent<HTMLTextAreaElement>) => {
- const value = event.currentTarget.value;
- setText(value);
- window.localStorage.setItem(draftLocalStorageKey, value);
- }}
- />
- ) : (
- <TimelinePostEditImage onSelect={onImageSelect} />
- )}
- </Col>
- <Col xs="auto" className="align-self-end m-1">
- {(() => {
- if (state === "input") {
- return (
- <>
- <div className="d-block text-center mt-1 mb-2">
- <Svg
- onLoad={notifyHeightChange}
- src={kind === "text" ? imageIcon : textIcon}
- className="icon-button"
- onClick={toggleKind}
- />
- </div>
- <Button
- variant="primary"
- onClick={onSend}
- disabled={!canSend}
- >
- {t("timeline.send")}
- </Button>
- </>
- );
- } else {
- return <Spinner variant="primary" animation="border" />;
- }
- })()}
- </Col>
- </Row>
- </div>
- );
-};
-
-export default TimelinePostEdit;
diff --git a/Timeline/ClientApp/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx b/Timeline/ClientApp/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx
deleted file mode 100644
index 87638f31..00000000
--- a/Timeline/ClientApp/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx
+++ /dev/null
@@ -1,72 +0,0 @@
-import React from "react";
-
-import {
- TimelineVisibility,
- kTimelineVisibilities,
- TimelineChangePropertyRequest,
-} from "@/services/timeline";
-
-import OperationDialog, {
- OperationSelectInputInfoOption,
-} from "../common/OperationDialog";
-
-export interface TimelinePropertyInfo {
- visibility: TimelineVisibility;
- description: string;
-}
-
-export interface TimelinePropertyChangeDialogProps {
- open: boolean;
- close: () => void;
- oldInfo: TimelinePropertyInfo;
- onProcess: (request: TimelineChangePropertyRequest) => Promise<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
-) => {
- return (
- <OperationDialog
- title={"timeline.dialogChangeProperty.title"}
- titleColor="default"
- inputScheme={[
- {
- type: "select",
- label: "timeline.dialogChangeProperty.visibility",
- options: kTimelineVisibilities.map<OperationSelectInputInfoOption>(
- (v) => ({
- label: labelMap[v],
- value: v,
- })
- ),
- initValue: props.oldInfo.visibility,
- },
- {
- type: "text",
- label: "timeline.dialogChangeProperty.description",
- initValue: props.oldInfo.description,
- },
- ]}
- open={props.open}
- close={props.close}
- onProcess={([newVisibility, newDescription]) => {
- const req: TimelineChangePropertyRequest = {};
- if (newVisibility !== props.oldInfo.visibility) {
- req.visibility = newVisibility as TimelineVisibility;
- }
- if (newDescription !== props.oldInfo.description) {
- req.description = newDescription as string;
- }
- return props.onProcess(req);
- }}
- />
- );
-};
-
-export default TimelinePropertyChangeDialog;
diff --git a/Timeline/ClientApp/src/app/views/timeline-common/TimelineTop.tsx b/Timeline/ClientApp/src/app/views/timeline-common/TimelineTop.tsx
deleted file mode 100644
index 93a2a32c..00000000
--- a/Timeline/ClientApp/src/app/views/timeline-common/TimelineTop.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import React from "react";
-
-export interface TimelineTopProps {
- height?: number | string;
- children?: React.ReactElement;
-}
-
-const TimelineTop: React.FC<TimelineTopProps> = ({ height, children }) => {
- return (
- <div style={{ height: height }} className="timeline-top">
- <div className="timeline-line-area-container">
- <div className="timeline-line-area">
- <div className="timeline-line-segment"></div>
- </div>
- </div>
- {children}
- </div>
- );
-};
-
-export default TimelineTop;
diff --git a/Timeline/ClientApp/src/app/views/timeline-common/timeline-common.sass b/Timeline/ClientApp/src/app/views/timeline-common/timeline-common.sass
deleted file mode 100644
index 4151bfcc..00000000
--- a/Timeline/ClientApp/src/app/views/timeline-common/timeline-common.sass
+++ /dev/null
@@ -1,146 +0,0 @@
-@use 'sass:color'
-
-.timeline
- z-index: 0
- position: relative
-
- &-item
- display: flex
-
-$timeline-line-width: 7px
-$timeline-line-node-radius: 18px
-$timeline-line-color: $primary
-$timeline-line-color-current: #36c2e6
-
-@keyframes timeline-line-node-noncurrent
- from
- background: $timeline-line-color
-
- to
- background: color.adjust($timeline-line-color, $lightness: +10%)
- box-shadow: 0 0 20px 3px color.adjust($timeline-line-color, $lightness: +10%, $alpha: -0.1)
-
-@keyframes timeline-line-node-current
- from
- background: $timeline-line-color-current
-
- to
- background: color.adjust($timeline-line-color-current, $lightness: +10%)
- box-shadow: 0 0 20px 3px color.adjust($timeline-line-color-current, $lightness: +10%, $alpha: -0.1)
-
-.timeline-line
- &-area-container
- display: flex
- justify-content: flex-end
- padding-right: 5px
-
- flex: 0 0 auto
- width: 60px
-
- &-area
- display: flex
- flex-direction: column
- align-items: center
- width: 30px
-
- &-segment
- width: $timeline-line-width
- background: $timeline-line-color
-
- &.start
- height: 14px
- flex: 0 0 auto
-
- &.end
- flex: 1 1 auto
-
- &.current-end
- height: 20px
- flex: 0 0 auto
- background: linear-gradient($timeline-line-color-current, transparent)
-
- &-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
- left: -1px
- top: -1px
- border-radius: 50%
- box-sizing: border-box
- z-index: 1
- animation: 1s infinite alternate
- animation-name: timeline-line-node-noncurrent
-
-.timeline-top
- display: flex
- justify-content: space-between
-
- .timeline-line-segment
- flex: 1 1 auto
-
-.current
- .timeline-line
- &-segment
-
- &.start
- background: linear-gradient($timeline-line-color, $timeline-line-color-current)
-
- &.end
- background: $timeline-line-color-current
-
- &-node
- animation-name: timeline-line-node-current
-
-.timeline-content-area
- padding: 10px 0
- flex-grow: 1
-
-.timeline-item-delete-button
- position: absolute
- right: 0
- bottom: 0
-
-.timeline-content
- white-space: pre-line
-
-.timeline-content-image
- max-width: 60%
- max-height: 200px
-
-.timeline-post-edit-image
- max-width: 100px
- max-height: 100px
-
-.mask
- background: change-color($color: white, $alpha: 0.8)
- z-index: 100
-
-.timeline-page-top-space
- transition: height 0.5s
-
-.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
- z-index: 1
- top: 56px
- right: 0
- margin: 0.5em
diff --git a/Timeline/ClientApp/src/app/views/timeline/TimelineDeleteDialog.tsx b/Timeline/ClientApp/src/app/views/timeline/TimelineDeleteDialog.tsx
deleted file mode 100644
index 894b8195..00000000
--- a/Timeline/ClientApp/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 { timelineService } from "@/services/timeline";
-
-import OperationDialog from "../common/OperationDialog";
-
-interface TimelineDeleteDialog {
- open: boolean;
- name: string;
- close: () => void;
-}
-
-const TimelineDeleteDialog: React.FC<TimelineDeleteDialog> = (props) => {
- const history = useHistory();
-
- const { name } = props;
-
- return (
- <OperationDialog
- open={props.open}
- close={props.close}
- title="timeline.deleteDialog.title"
- titleColor="danger"
- inputPrompt={() => {
- return (
- <Trans i18nKey="timeline.deleteDialog.inputPrompt">
- 0<code className="mx-2">{{ name }}</code>2
- </Trans>
- );
- }}
- inputScheme={[
- {
- type: "text",
- validator: (value) => {
- if (value !== name) {
- return "timeline.deleteDialog.notMatch";
- } else {
- return null;
- }
- },
- },
- ]}
- onProcess={() => {
- return timelineService.deleteTimeline(name).toPromise();
- }}
- onSuccessAndClose={() => {
- history.replace("/");
- }}
- />
- );
-};
-
-export default TimelineDeleteDialog;
diff --git a/Timeline/ClientApp/src/app/views/timeline/TimelineInfoCard.tsx b/Timeline/ClientApp/src/app/views/timeline/TimelineInfoCard.tsx
deleted file mode 100644
index 2d787709..00000000
--- a/Timeline/ClientApp/src/app/views/timeline/TimelineInfoCard.tsx
+++ /dev/null
@@ -1,85 +0,0 @@
-import React from "react";
-import { useTranslation } from "react-i18next";
-import { Dropdown, Button } from "react-bootstrap";
-
-import { useAvatar } from "@/services/user";
-import { timelineVisibilityTooltipTranslationMap } from "@/services/timeline";
-
-import BlobImage from "../common/BlobImage";
-import { TimelineCardComponentProps } from "../timeline-common/TimelinePageTemplateUI";
-import InfoCardTemplate from "../timeline-common/InfoCardTemplate";
-
-export type OrdinaryTimelineManageItem = "delete";
-
-export type TimelineInfoCardProps = TimelineCardComponentProps<
- OrdinaryTimelineManageItem
->;
-
-const TimelineInfoCard: React.FC<TimelineInfoCardProps> = (props) => {
- const {
- timeline,
- collapse,
- onMember,
- onManage,
- syncStatus,
- toggleCollapse,
- } = props;
-
- const { t } = useTranslation();
-
- const avatar = useAvatar(timeline?.owner?.username);
-
- return (
- <InfoCardTemplate
- className={props.className}
- syncStatus={syncStatus}
- collapse={collapse}
- toggleCollapse={toggleCollapse}
- >
- <h3 className="text-primary mx-3 d-inline-block align-middle">
- {timeline.name}
- </h3>
- <div className="d-inline-block align-middle">
- <BlobImage blob={avatar} className="avatar small rounded-circle" />
- {timeline.owner.nickname}
- <small className="ml-3 text-secondary">
- @{timeline.owner.username}
- </small>
- </div>
- <p className="mb-0">{timeline.description}</p>
- <small className="mt-1 d-block">
- {t(timelineVisibilityTooltipTranslationMap[timeline.visibility])}
- </small>
- <div className="text-right mt-2">
- {onManage != null ? (
- <Dropdown>
- <Dropdown.Toggle variant="outline-primary">
- {t("timeline.manage")}
- </Dropdown.Toggle>
- <Dropdown.Menu>
- <Dropdown.Item onClick={() => onManage("property")}>
- {t("timeline.manageItem.property")}
- </Dropdown.Item>
- <Dropdown.Item onClick={onMember}>
- {t("timeline.manageItem.member")}
- </Dropdown.Item>
- <Dropdown.Divider />
- <Dropdown.Item
- className="text-danger"
- onClick={() => onManage("delete")}
- >
- {t("timeline.manageItem.delete")}
- </Dropdown.Item>
- </Dropdown.Menu>
- </Dropdown>
- ) : (
- <Button variant="outline-primary" onClick={onMember}>
- {t("timeline.memberButton")}
- </Button>
- )}
- </div>
- </InfoCardTemplate>
- );
-};
-
-export default TimelineInfoCard;
diff --git a/Timeline/ClientApp/src/app/views/timeline/TimelinePageUI.tsx b/Timeline/ClientApp/src/app/views/timeline/TimelinePageUI.tsx
deleted file mode 100644
index 67ea699e..00000000
--- a/Timeline/ClientApp/src/app/views/timeline/TimelinePageUI.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-import React from "react";
-
-import TimelinePageTemplateUI, {
- TimelinePageTemplateUIProps,
-} from "../timeline-common/TimelinePageTemplateUI";
-
-import TimelineInfoCard, {
- OrdinaryTimelineManageItem,
-} from "./TimelineInfoCard";
-
-export type TimelinePageUIProps = Omit<
- TimelinePageTemplateUIProps<OrdinaryTimelineManageItem>,
- "CardComponent"
->;
-
-const TimelinePageUI: React.FC<TimelinePageUIProps> = (props) => {
- return <TimelinePageTemplateUI {...props} CardComponent={TimelineInfoCard} />;
-};
-
-export default TimelinePageUI;
diff --git a/Timeline/ClientApp/src/app/views/timeline/index.tsx b/Timeline/ClientApp/src/app/views/timeline/index.tsx
deleted file mode 100644
index 225a1a59..00000000
--- a/Timeline/ClientApp/src/app/views/timeline/index.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-import React from "react";
-import { useParams } from "react-router";
-
-import TimelinePageTemplate from "../timeline-common/TimelinePageTemplate";
-
-import TimelinePageUI from "./TimelinePageUI";
-import { OrdinaryTimelineManageItem } from "./TimelineInfoCard";
-import TimelineDeleteDialog from "./TimelineDeleteDialog";
-
-const TimelinePage: React.FC = (_) => {
- const { name } = useParams<{ name: string }>();
-
- const [dialog, setDialog] = React.useState<OrdinaryTimelineManageItem | null>(
- null
- );
-
- let dialogElement: React.ReactElement | undefined;
- if (dialog === "delete") {
- dialogElement = (
- <TimelineDeleteDialog open close={() => setDialog(null)} name={name} />
- );
- }
-
- return (
- <>
- <TimelinePageTemplate
- name={name}
- UiComponent={TimelinePageUI}
- onManage={(item) => setDialog(item)}
- notFoundI18nKey="timeline.timelineNotExist"
- />
- {dialogElement}
- </>
- );
-};
-
-export default TimelinePage;
diff --git a/Timeline/ClientApp/src/app/views/timeline/timeline.sass b/Timeline/ClientApp/src/app/views/timeline/timeline.sass
deleted file mode 100644
index e69de29b..00000000
--- a/Timeline/ClientApp/src/app/views/timeline/timeline.sass
+++ /dev/null
diff --git a/Timeline/ClientApp/src/app/views/user/ChangeAvatarDialog.tsx b/Timeline/ClientApp/src/app/views/user/ChangeAvatarDialog.tsx
deleted file mode 100644
index ffa2218b..00000000
--- a/Timeline/ClientApp/src/app/views/user/ChangeAvatarDialog.tsx
+++ /dev/null
@@ -1,302 +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 ImageCropper, { Clip, applyClipToImage } from "../common/ImageCropper";
-
-export interface ChangeAvatarDialogProps {
- open: boolean;
- close: () => void;
- process: (blob: Blob) => Promise<void>;
-}
-
-const ChangeAvatarDialog: React.FC<ChangeAvatarDialogProps> = (props) => {
- const { t } = useTranslation();
-
- 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
- >("userPage.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 process = props.process;
-
- const upload = React.useCallback(() => {
- if (resultBlob == null) {
- throw new UiLogicError();
- }
-
- setState("uploading");
- process(resultBlob).then(
- () => {
- setState("success");
- },
- (e: unknown) => {
- setState("error");
- setMessage({ type: "custom", text: (e as AxiosError).message });
- }
- );
- }, [resultBlob, process]);
-
- 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("userPage.dialogChangeAvatar.previewImgAlt")}
- />
- </Row>
- );
- };
-
- return (
- <Modal show={props.open} onHide={close}>
- <Modal.Header>
- <Modal.Title> {t("userPage.dialogChangeAvatar.title")}</Modal.Title>
- </Modal.Header>
- {(() => {
- if (state === "select") {
- return (
- <>
- <Modal.Body className="container">
- <Row>{t("userPage.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("userPage.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("userPage.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("userPage.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("userPage.dialogChangeAvatar.upload")}
- </Button>
- </Modal.Footer>
- </>
- );
- } else if (state === "uploading") {
- return (
- <>
- <Modal.Body className="container">
- {createPreviewRow()}
- <Row>{t("userPage.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/Timeline/ClientApp/src/app/views/user/ChangeNicknameDialog.tsx b/Timeline/ClientApp/src/app/views/user/ChangeNicknameDialog.tsx
deleted file mode 100644
index 251b18c5..00000000
--- a/Timeline/ClientApp/src/app/views/user/ChangeNicknameDialog.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-import React from "react";
-
-import OperationDialog from "../common/OperationDialog";
-
-export interface ChangeNicknameDialogProps {
- open: boolean;
- close: () => void;
- onProcess: (newNickname: string) => Promise<void>;
-}
-
-const ChangeNicknameDialog: React.FC<ChangeNicknameDialogProps> = (props) => {
- return (
- <OperationDialog
- open={props.open}
- title="userPage.dialogChangeNickname.title"
- titleColor="default"
- inputScheme={[
- { type: "text", label: "userPage.dialogChangeNickname.inputLabel" },
- ]}
- onProcess={([newNickname]) => {
- return props.onProcess(newNickname as string);
- }}
- close={props.close}
- />
- );
-};
-
-export default ChangeNicknameDialog;
diff --git a/Timeline/ClientApp/src/app/views/user/UserInfoCard.tsx b/Timeline/ClientApp/src/app/views/user/UserInfoCard.tsx
deleted file mode 100644
index 888fb18a..00000000
--- a/Timeline/ClientApp/src/app/views/user/UserInfoCard.tsx
+++ /dev/null
@@ -1,80 +0,0 @@
-import React from "react";
-import { useTranslation } from "react-i18next";
-import { Dropdown, Button } from "react-bootstrap";
-
-import { timelineVisibilityTooltipTranslationMap } from "@/services/timeline";
-import { useAvatar } from "@/services/user";
-
-import BlobImage from "../common/BlobImage";
-import { TimelineCardComponentProps } from "../timeline-common/TimelinePageTemplateUI";
-import InfoCardTemplate from "../timeline-common/InfoCardTemplate";
-
-export type PersonalTimelineManageItem = "avatar" | "nickname";
-
-export type UserInfoCardProps = TimelineCardComponentProps<
- PersonalTimelineManageItem
->;
-
-const UserInfoCard: React.FC<UserInfoCardProps> = (props) => {
- const {
- timeline,
- collapse,
- onMember,
- onManage,
- syncStatus,
- toggleCollapse,
- } = props;
- const { t } = useTranslation();
-
- const avatar = useAvatar(timeline?.owner?.username);
-
- return (
- <InfoCardTemplate
- className={props.className}
- syncStatus={syncStatus}
- collapse={collapse}
- toggleCollapse={toggleCollapse}
- >
- <div>
- <BlobImage blob={avatar} className="avatar" />
- {timeline.owner.nickname}
- <small className="ml-3 text-secondary">
- @{timeline.owner.username}
- </small>
- </div>
- <p className="mb-0">{timeline.description}</p>
- <small className="mt-1 d-block">
- {t(timelineVisibilityTooltipTranslationMap[timeline.visibility])}
- </small>
- <div className="text-right mt-2">
- {onManage != null ? (
- <Dropdown>
- <Dropdown.Toggle variant="outline-primary">
- {t("timeline.manage")}
- </Dropdown.Toggle>
- <Dropdown.Menu>
- <Dropdown.Item onClick={() => onManage("nickname")}>
- {t("timeline.manageItem.nickname")}
- </Dropdown.Item>
- <Dropdown.Item onClick={() => onManage("avatar")}>
- {t("timeline.manageItem.avatar")}
- </Dropdown.Item>
- <Dropdown.Item onClick={() => onManage("property")}>
- {t("timeline.manageItem.property")}
- </Dropdown.Item>
- <Dropdown.Item onClick={onMember}>
- {t("timeline.manageItem.member")}
- </Dropdown.Item>
- </Dropdown.Menu>
- </Dropdown>
- ) : (
- <Button variant="outline-primary" onClick={onMember}>
- {t("timeline.memberButton")}
- </Button>
- )}
- </div>
- </InfoCardTemplate>
- );
-};
-
-export default UserInfoCard;
diff --git a/Timeline/ClientApp/src/app/views/user/UserPageUI.tsx b/Timeline/ClientApp/src/app/views/user/UserPageUI.tsx
deleted file mode 100644
index d405399c..00000000
--- a/Timeline/ClientApp/src/app/views/user/UserPageUI.tsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import React from "react";
-
-import TimelinePageTemplateUI, {
- TimelinePageTemplateUIProps,
-} from "../timeline-common/TimelinePageTemplateUI";
-
-import UserInfoCard, { PersonalTimelineManageItem } from "./UserInfoCard";
-
-export type UserPageUIProps = Omit<
- TimelinePageTemplateUIProps<PersonalTimelineManageItem>,
- "CardComponent"
->;
-
-const UserPageUI: React.FC<UserPageUIProps> = (props) => {
- return <TimelinePageTemplateUI {...props} CardComponent={UserInfoCard} />;
-};
-
-export default UserPageUI;
diff --git a/Timeline/ClientApp/src/app/views/user/index.tsx b/Timeline/ClientApp/src/app/views/user/index.tsx
deleted file mode 100644
index 7c0b1563..00000000
--- a/Timeline/ClientApp/src/app/views/user/index.tsx
+++ /dev/null
@@ -1,72 +0,0 @@
-import React, { useState } from "react";
-import { useParams } from "react-router";
-
-import { UiLogicError } from "@/common";
-import { useUser, userInfoService } from "@/services/user";
-
-import TimelinePageTemplate from "../timeline-common/TimelinePageTemplate";
-
-import UserPageUI from "./UserPageUI";
-import { PersonalTimelineManageItem } from "./UserInfoCard";
-import ChangeNicknameDialog from "./ChangeNicknameDialog";
-import ChangeAvatarDialog from "./ChangeAvatarDialog";
-
-const UserPage: React.FC = (_) => {
- const { username } = useParams<{ username: string }>();
-
- const user = useUser();
-
- const [dialog, setDialog] = useState<null | PersonalTimelineManageItem>(null);
-
- let dialogElement: React.ReactElement | undefined;
-
- const closeDialogHandler = (): void => {
- setDialog(null);
- };
-
- if (dialog === "nickname") {
- if (user == null) {
- throw new UiLogicError("Change nickname without login.");
- }
-
- dialogElement = (
- <ChangeNicknameDialog
- open
- close={closeDialogHandler}
- onProcess={(newNickname) =>
- userInfoService.setNickname(username, newNickname)
- }
- />
- );
- } else if (dialog === "avatar") {
- if (user == null) {
- throw new UiLogicError("Change avatar without login.");
- }
-
- dialogElement = (
- <ChangeAvatarDialog
- open
- close={closeDialogHandler}
- process={(file) => userInfoService.setAvatar(username, file)}
- />
- );
- }
-
- const onManage = React.useCallback((item: PersonalTimelineManageItem) => {
- setDialog(item);
- }, []);
-
- return (
- <>
- <TimelinePageTemplate
- name={`@${username}`}
- UiComponent={UserPageUI}
- onManage={onManage}
- notFoundI18nKey="timeline.userNotExist"
- />
- {dialogElement}
- </>
- );
-};
-
-export default UserPage;
diff --git a/Timeline/ClientApp/src/app/views/user/user.sass b/Timeline/ClientApp/src/app/views/user/user.sass
deleted file mode 100644
index 5b7fcae7..00000000
--- a/Timeline/ClientApp/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