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