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