diff options
Diffstat (limited to 'FrontEnd/src/app/views')
51 files changed, 4777 insertions, 0 deletions
diff --git a/FrontEnd/src/app/views/about/about.sass b/FrontEnd/src/app/views/about/about.sass new file mode 100644 index 00000000..f4d00cae --- /dev/null +++ b/FrontEnd/src/app/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/app/views/about/author-avatar.png b/FrontEnd/src/app/views/about/author-avatar.png Binary files differnew file mode 100644 index 00000000..d890d8d0 --- /dev/null +++ b/FrontEnd/src/app/views/about/author-avatar.png diff --git a/FrontEnd/src/app/views/about/github.png b/FrontEnd/src/app/views/about/github.png Binary files differnew file mode 100644 index 00000000..ea6ff545 --- /dev/null +++ b/FrontEnd/src/app/views/about/github.png diff --git a/FrontEnd/src/app/views/about/index.tsx b/FrontEnd/src/app/views/about/index.tsx new file mode 100644 index 00000000..e7771cec --- /dev/null +++ b/FrontEnd/src/app/views/about/index.tsx @@ -0,0 +1,164 @@ +import React from "react"; +import { useTranslation, Trans } from "react-i18next"; + +import authorAvatarUrl from "./author-avatar.png"; +import githubLogoUrl from "./github.png"; + +const frontendCredits: { + name: string; + url: string; +}[] = [ + { + name: "reactjs", + url: "https://reactjs.org", + }, + { + name: "typescript", + url: "https://www.typescriptlang.org", + }, + { + name: "bootstrap", + url: "https://getbootstrap.com", + }, + { + name: "react-bootstrap", + url: "https://react-bootstrap.github.io", + }, + { + name: "babeljs", + url: "https://babeljs.io", + }, + { + name: "webpack", + url: "https://webpack.js.org", + }, + { + name: "sass", + url: "https://sass-lang.com", + }, + { + name: "eslint", + url: "https://eslint.org", + }, + { + name: "prettier", + url: "https://prettier.io", + }, + { + name: "pepjs", + url: "https://github.com/jquery/PEP", + }, + { + name: "react-inlinesvg", + url: "https://github.com/gilbarbara/react-inlinesvg", + }, +]; + +const backendCredits: { + name: string; + url: string; +}[] = [ + { + name: "ASP.NET Core", + url: "https://dotnet.microsoft.com/learn/aspnet/what-is-aspnet-core", + }, + { name: "sqlite", url: "https://sqlite.org" }, + { + name: "ImageSharp", + url: "https://github.com/SixLabors/ImageSharp", + }, +]; + +const AboutPage: React.FC = () => { + const { t } = useTranslation(); + + return ( + <div className="mt-appbar px-2 mb-4"> + <div className="container mt-4 py-3 shadow border border-primary rounded bg-light"> + <h4 id="author-info">{t("about.author.title")}</h4> + <div> + <div className="d-flex"> + <img + src={authorAvatarUrl} + className="align-self-start avatar large rounded-circle" + /> + <div> + <p> + <small>{t("about.author.fullname")}</small> + <span className="text-primary">杨宇千</span> + </p> + <p> + <small>{t("about.author.nickname")}</small> + <span className="text-primary">crupest</span> + </p> + <p> + <small>{t("about.author.introduction")}</small> + {t("about.author.introductionContent")} + </p> + </div> + </div> + <p> + <small>{t("about.author.links")}</small> + <a + href="https://github.com/crupest" + target="_blank" + rel="noopener noreferrer" + > + <img src={githubLogoUrl} className="about-link-icon text-body" /> + </a> + </p> + </div> + </div> + <div className="container mt-4 py-3 shadow border border-primary rounded bg-light"> + <h4>{t("about.site.title")}</h4> + <p> + <Trans i18nKey="about.site.content"> + 0<span className="text-primary">1</span>2<b>3</b>4 + <a href="#author-info">5</a>6 + </Trans> + </p> + <p> + <a + href="https://github.com/crupest/Timeline" + target="_blank" + rel="noopener noreferrer" + > + {t("about.site.repo")} + </a> + </p> + </div> + <div className="container mt-4 py-3 shadow border border-primary rounded bg-light"> + <h4>{t("about.credits.title")}</h4> + <p>{t("about.credits.content")}</p> + <p>{t("about.credits.frontend")}</p> + <ul> + {frontendCredits.map((item, index) => { + return ( + <li key={index}> + <a href={item.url} target="_blank" rel="noopener noreferrer"> + {item.name} + </a> + </li> + ); + })} + <li>...</li> + </ul> + <p>{t("about.credits.backend")}</p> + <ul> + {backendCredits.map((item, index) => { + return ( + <li key={index}> + <a href={item.url} target="_blank" rel="noopener noreferrer"> + {item.name} + </a> + </li> + ); + })} + <li>...</li> + </ul> + </div> + </div> + ); +}; + +export default AboutPage; diff --git a/FrontEnd/src/app/views/admin/Admin.tsx b/FrontEnd/src/app/views/admin/Admin.tsx new file mode 100644 index 00000000..9c0250e7 --- /dev/null +++ b/FrontEnd/src/app/views/admin/Admin.tsx @@ -0,0 +1,75 @@ +import React, { Fragment } from "react"; +import { + Redirect, + Route, + Switch, + useRouteMatch, + useHistory, +} from "react-router"; +import { Nav } from "react-bootstrap"; + +import { UserWithToken } from "@/services/user"; + +import UserAdmin from "./UserAdmin"; + +interface AdminProps { + user: UserWithToken; +} + +const Admin: React.FC<AdminProps> = (props) => { + const match = useRouteMatch(); + const history = useHistory(); + type TabNames = "users" | "more"; + + const tabName = history.location.pathname.replace(match.path + "/", ""); + + function toggle(newTab: TabNames): void { + history.push(`${match.url}/${newTab}`); + } + + const createRoute = ( + name: string, + body: React.ReactNode + ): React.ReactNode => { + return ( + <Route path={`${match.path}/${name}`}> + <div style={{ height: 56 }} className="flex-fix-length" /> + <Nav variant="tabs"> + <Nav.Item> + <Nav.Link + active={tabName === "users"} + onClick={() => { + toggle("users"); + }} + > + Users + </Nav.Link> + </Nav.Item> + <Nav.Item> + <Nav.Link + active={tabName === "more"} + onClick={() => { + toggle("more"); + }} + > + More + </Nav.Link> + </Nav.Item> + </Nav> + {body} + </Route> + ); + }; + + return ( + <Fragment> + <Switch> + <Redirect from={match.path} to={`${match.path}/users`} exact /> + {createRoute("users", <UserAdmin user={props.user} />)} + {createRoute("more", <div>More Page Works</div>)} + </Switch> + </Fragment> + ); +}; + +export default Admin; diff --git a/FrontEnd/src/app/views/admin/UserAdmin.tsx b/FrontEnd/src/app/views/admin/UserAdmin.tsx new file mode 100644 index 00000000..18b77ca8 --- /dev/null +++ b/FrontEnd/src/app/views/admin/UserAdmin.tsx @@ -0,0 +1,460 @@ +import React, { useState, useEffect } from "react"; +import axios from "axios"; +import { + ListGroup, + Row, + Col, + Dropdown, + Spinner, + Button, +} from "react-bootstrap"; + +import OperationDialog from "../common/OperationDialog"; +import { User, UserWithToken } from "@/services/user"; + +const apiBaseUrl = "/api"; + +async function fetchUserList(_token: string): Promise<User[]> { + const res = await axios.get<User[]>(`${apiBaseUrl}/users`); + return res.data; +} + +interface CreateUserInfo { + username: string; + password: string; + administrator: boolean; +} + +async function createUser(user: CreateUserInfo, token: string): Promise<User> { + const res = await axios.post<User>( + `${apiBaseUrl}/userop/createuser?token=${token}`, + user + ); + return res.data; +} + +function deleteUser(username: string, token: string): Promise<void> { + return axios.delete(`${apiBaseUrl}/users/${username}?token=${token}`); +} + +function changeUsername( + oldUsername: string, + newUsername: string, + token: string +): Promise<void> { + return axios.patch(`${apiBaseUrl}/users/${oldUsername}?token=${token}`, { + username: newUsername, + }); +} + +function changePassword( + username: string, + newPassword: string, + token: string +): Promise<void> { + return axios.patch(`${apiBaseUrl}/users/${username}?token=${token}`, { + password: newPassword, + }); +} + +function changePermission( + username: string, + newPermission: boolean, + token: string +): Promise<void> { + return axios.patch(`${apiBaseUrl}/users/${username}?token=${token}`, { + administrator: newPermission, + }); +} + +const kChangeUsername = "changeusername"; +const kChangePassword = "changepassword"; +const kChangePermission = "changepermission"; +const kDelete = "delete"; + +type TChangeUsername = typeof kChangeUsername; +type TChangePassword = typeof kChangePassword; +type TChangePermission = typeof kChangePermission; +type TDelete = typeof kDelete; + +type ContextMenuItem = + | TChangeUsername + | TChangePassword + | TChangePermission + | TDelete; + +interface UserCardProps { + onContextMenu: (item: ContextMenuItem) => void; + user: User; +} + +const UserItem: React.FC<UserCardProps> = (props) => { + const user = props.user; + + const createClickCallback = (item: ContextMenuItem): (() => void) => { + return () => { + props.onContextMenu(item); + }; + }; + + return ( + <ListGroup.Item className="container"> + <Row className="align-items-center"> + <Col> + <p className="mb-0 text-primary">{user.username}</p> + <small + className={user.administrator ? "text-danger" : "text-secondary"} + > + {user.administrator ? "administrator" : "user"} + </small> + </Col> + <Col className="col-auto"> + <Dropdown> + <Dropdown.Toggle variant="warning" className="text-light"> + Manage + </Dropdown.Toggle> + <Dropdown.Menu> + <Dropdown.Item onClick={createClickCallback(kChangeUsername)}> + Change Username + </Dropdown.Item> + <Dropdown.Item onClick={createClickCallback(kChangePassword)}> + Change Password + </Dropdown.Item> + <Dropdown.Item onClick={createClickCallback(kChangePermission)}> + Change Permission + </Dropdown.Item> + <Dropdown.Item + className="text-danger" + onClick={createClickCallback(kDelete)} + > + Delete + </Dropdown.Item> + </Dropdown.Menu> + </Dropdown> + </Col> + </Row> + </ListGroup.Item> + ); +}; + +interface DialogProps { + open: boolean; + close: () => void; +} + +interface CreateUserDialogProps extends DialogProps { + process: (user: CreateUserInfo) => Promise<void>; +} + +const CreateUserDialog: React.FC<CreateUserDialogProps> = (props) => { + return ( + <OperationDialog + title="Create" + titleColor="create" + inputPrompt="You are creating a new user." + inputScheme={[ + { type: "text", label: "Username" }, + { type: "text", label: "Password" }, + { type: "bool", label: "Administrator" }, + ]} + onProcess={([username, password, administrator]) => + props.process({ + username: username as string, + password: password as string, + administrator: administrator as boolean, + }) + } + close={props.close} + open={props.open} + /> + ); +}; + +const UsernameLabel: React.FC = (props) => { + return <span style={{ color: "blue" }}>{props.children}</span>; +}; + +interface UserDeleteDialogProps extends DialogProps { + username: string; + process: () => Promise<void>; +} + +const UserDeleteDialog: React.FC<UserDeleteDialogProps> = (props) => { + return ( + <OperationDialog + open={props.open} + close={props.close} + title="Dangerous" + titleColor="dangerous" + inputPrompt={() => ( + <> + {"You are deleting user "} + <UsernameLabel>{props.username}</UsernameLabel> + {" !"} + </> + )} + onProcess={props.process} + /> + ); +}; + +interface UserModifyDialogProps<T> extends DialogProps { + username: string; + process: (value: T) => Promise<void>; +} + +const UserChangeUsernameDialog: React.FC<UserModifyDialogProps<string>> = ( + props +) => { + return ( + <OperationDialog + open={props.open} + close={props.close} + title="Caution" + titleColor="dangerous" + inputPrompt={() => ( + <> + {"You are change the username of user "} + <UsernameLabel>{props.username}</UsernameLabel> + {" !"} + </> + )} + inputScheme={[{ type: "text", label: "New Username" }]} + onProcess={([newUsername]) => { + return props.process(newUsername as string); + }} + /> + ); +}; + +const UserChangePasswordDialog: React.FC<UserModifyDialogProps<string>> = ( + props +) => { + return ( + <OperationDialog + open={props.open} + close={props.close} + title="Caution" + titleColor="dangerous" + inputPrompt={() => ( + <> + {"You are change the password of user "} + <UsernameLabel>{props.username}</UsernameLabel> + {" !"} + </> + )} + inputScheme={[{ type: "text", label: "New Password" }]} + onProcess={([newPassword]) => { + return props.process(newPassword as string); + }} + /> + ); +}; + +interface UserChangePermissionDialogProps extends DialogProps { + username: string; + newPermission: boolean; + process: () => Promise<void>; +} + +const UserChangePermissionDialog: React.FC<UserChangePermissionDialogProps> = ( + props +) => { + return ( + <OperationDialog + open={props.open} + close={props.close} + title="Caution" + titleColor="dangerous" + inputPrompt={() => ( + <> + {"You are change user "} + <UsernameLabel>{props.username}</UsernameLabel> + {" to "} + <span style={{ color: "orange" }}> + {props.newPermission ? "administrator" : "normal user"} + </span> + {" !"} + </> + )} + onProcess={props.process} + /> + ); +}; + +interface UserAdminProps { + user: UserWithToken; +} + +const UserAdmin: React.FC<UserAdminProps> = (props) => { + type DialogInfo = + | null + | { + type: "create"; + } + | { type: TDelete; username: string } + | { + type: TChangeUsername; + username: string; + } + | { + type: TChangePassword; + username: string; + } + | { + type: TChangePermission; + username: string; + newPermission: boolean; + }; + + const [users, setUsers] = useState<User[] | null>(null); + const [dialog, setDialog] = useState<DialogInfo>(null); + + const token = props.user.token; + + useEffect(() => { + let subscribe = true; + void fetchUserList(props.user.token).then((us) => { + if (subscribe) { + setUsers(us); + } + }); + return () => { + subscribe = false; + }; + }, [props.user]); + + let dialogNode: React.ReactNode; + if (dialog) + switch (dialog.type) { + case "create": + dialogNode = ( + <CreateUserDialog + open + close={() => setDialog(null)} + process={async (user) => { + const u = await createUser(user, token); + setUsers((oldUsers) => [...(oldUsers ?? []), u]); + }} + /> + ); + break; + case "delete": + dialogNode = ( + <UserDeleteDialog + open + close={() => setDialog(null)} + username={dialog.username} + process={async () => { + await deleteUser(dialog.username, token); + setUsers((oldUsers) => + (oldUsers ?? []).filter((u) => u.username !== dialog.username) + ); + }} + /> + ); + break; + case kChangeUsername: + dialogNode = ( + <UserChangeUsernameDialog + open + close={() => setDialog(null)} + username={dialog.username} + process={async (newUsername) => { + await changeUsername(dialog.username, newUsername, token); + setUsers((oldUsers) => { + const users = (oldUsers ?? []).slice(); + const findedUser = users.find( + (u) => u.username === dialog.username + ); + if (findedUser) findedUser.username = newUsername; + return users; + }); + }} + /> + ); + break; + case kChangePassword: + dialogNode = ( + <UserChangePasswordDialog + open + close={() => setDialog(null)} + username={dialog.username} + process={async (newPassword) => { + await changePassword(dialog.username, newPassword, token); + }} + /> + ); + break; + case kChangePermission: { + const newPermission = dialog.newPermission; + dialogNode = ( + <UserChangePermissionDialog + open + close={() => setDialog(null)} + username={dialog.username} + newPermission={newPermission} + process={async () => { + await changePermission(dialog.username, newPermission, token); + setUsers((oldUsers) => { + const users = (oldUsers ?? []).slice(); + const findedUser = users.find( + (u) => u.username === dialog.username + ); + if (findedUser) findedUser.administrator = newPermission; + return users; + }); + }} + /> + ); + break; + } + } + + if (users) { + const userComponents = users.map((user) => { + return ( + <UserItem + key={user.username} + user={user} + onContextMenu={(item) => { + setDialog( + item === kChangePermission + ? { + type: kChangePermission, + username: user.username, + newPermission: !user.administrator, + } + : { + type: item, + username: user.username, + } + ); + }} + /> + ); + }); + + return ( + <> + <Button + variant="success" + onClick={() => + setDialog({ + type: "create", + }) + } + className="align-self-end" + > + Create User + </Button> + {userComponents} + {dialogNode} + </> + ); + } else { + return <Spinner animation="border" />; + } +}; + +export default UserAdmin; diff --git a/FrontEnd/src/app/views/common/AppBar.tsx b/FrontEnd/src/app/views/common/AppBar.tsx new file mode 100644 index 00000000..ee4ead8f --- /dev/null +++ b/FrontEnd/src/app/views/common/AppBar.tsx @@ -0,0 +1,64 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { LinkContainer } from "react-router-bootstrap"; +import { Navbar, Nav } from "react-bootstrap"; + +import { useUser, useAvatar } from "@/services/user"; + +import TimelineLogo from "./TimelineLogo"; +import BlobImage from "./BlobImage"; + +const AppBar: React.FC = (_) => { + const user = useUser(); + const avatar = useAvatar(user?.username); + + const { t } = useTranslation(); + + const isAdministrator = user && user.administrator; + + return ( + <Navbar bg="primary" variant="dark" expand="md" sticky="top"> + <LinkContainer to="/"> + <Navbar.Brand className="d-flex align-items-center"> + <TimelineLogo style={{ height: "1em" }} /> + Timeline + </Navbar.Brand> + </LinkContainer> + + <Navbar.Toggle /> + <Navbar.Collapse> + <Nav className="mr-auto"> + <LinkContainer to="/settings"> + <Nav.Link>{t("nav.settings")}</Nav.Link> + </LinkContainer> + + <LinkContainer to="/about"> + <Nav.Link>{t("nav.about")}</Nav.Link> + </LinkContainer> + + {isAdministrator && ( + <LinkContainer to="/admin"> + <Nav.Link>Administration</Nav.Link> + </LinkContainer> + )} + </Nav> + <Nav className="ml-auto mr-2"> + {user != null ? ( + <LinkContainer to={`/users/${user.username}`}> + <BlobImage + className="avatar small rounded-circle bg-white" + blob={avatar} + /> + </LinkContainer> + ) : ( + <LinkContainer to="/login"> + <Nav.Link>{t("nav.login")}</Nav.Link> + </LinkContainer> + )} + </Nav> + </Navbar.Collapse> + </Navbar> + ); +}; + +export default AppBar; diff --git a/FrontEnd/src/app/views/common/BlobImage.tsx b/FrontEnd/src/app/views/common/BlobImage.tsx new file mode 100644 index 00000000..0dd25c52 --- /dev/null +++ b/FrontEnd/src/app/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/app/views/common/ImageCropper.tsx b/FrontEnd/src/app/views/common/ImageCropper.tsx new file mode 100644 index 00000000..b9db8b99 --- /dev/null +++ b/FrontEnd/src/app/views/common/ImageCropper.tsx @@ -0,0 +1,306 @@ +import React from "react"; +import clsx from "clsx"; + +import { UiLogicError } from "@/common"; + +export interface Clip { + left: number; + top: number; + width: number; +} + +interface NormailizedClip extends Clip { + height: number; +} + +interface ImageInfo { + width: number; + height: number; + landscape: boolean; + ratio: number; + maxClipWidth: number; + maxClipHeight: number; +} + +interface ImageCropperSavedState { + clip: NormailizedClip; + x: number; + y: number; + pointerId: number; +} + +export interface ImageCropperProps { + clip: Clip | null; + imageUrl: string; + onChange: (clip: Clip) => void; + imageElementCallback?: (element: HTMLImageElement | null) => void; + className?: string; +} + +const ImageCropper = (props: ImageCropperProps): React.ReactElement => { + const { clip, imageUrl, onChange, imageElementCallback, className } = props; + + const [oldState, setOldState] = React.useState<ImageCropperSavedState | null>( + null + ); + const [imageInfo, setImageInfo] = React.useState<ImageInfo | null>(null); + + const normalizeClip = (c: Clip | null | undefined): NormailizedClip => { + if (c == null) { + return { left: 0, top: 0, width: 0, height: 0 }; + } + + return { + left: c.left || 0, + top: c.top || 0, + width: c.width || 0, + height: imageInfo != null ? (c.width || 0) / imageInfo.ratio : 0, + }; + }; + + const c = normalizeClip(clip); + + const imgElementRef = React.useRef<HTMLImageElement | null>(null); + + const onImageRef = React.useCallback( + (e: HTMLImageElement | null) => { + imgElementRef.current = e; + if (imageElementCallback != null && e == null) { + imageElementCallback(null); + } + }, + [imageElementCallback] + ); + + const onImageLoad = React.useCallback( + (e: React.SyntheticEvent<HTMLImageElement>) => { + const img = e.currentTarget; + const landscape = img.naturalWidth >= img.naturalHeight; + + const info = { + width: img.naturalWidth, + height: img.naturalHeight, + landscape, + ratio: img.naturalHeight / img.naturalWidth, + maxClipWidth: landscape ? img.naturalHeight / img.naturalWidth : 1, + maxClipHeight: landscape ? 1 : img.naturalWidth / img.naturalHeight, + }; + setImageInfo(info); + onChange({ left: 0, top: 0, width: info.maxClipWidth }); + if (imageElementCallback != null) { + imageElementCallback(img); + } + }, + [onChange, imageElementCallback] + ); + + const onPointerDown = React.useCallback( + (e: React.PointerEvent) => { + if (oldState != null) return; + e.currentTarget.setPointerCapture(e.pointerId); + setOldState({ + x: e.clientX, + y: e.clientY, + clip: c, + pointerId: e.pointerId, + }); + }, + [oldState, c] + ); + + const onPointerUp = React.useCallback( + (e: React.PointerEvent) => { + if (oldState == null || oldState.pointerId !== e.pointerId) return; + e.currentTarget.releasePointerCapture(e.pointerId); + setOldState(null); + }, + [oldState] + ); + + const onPointerMove = React.useCallback( + (e: React.PointerEvent) => { + if (oldState == null) return; + + const oldClip = oldState.clip; + + const movement = { x: e.clientX - oldState.x, y: e.clientY - oldState.y }; + + const { current: imgElement } = imgElementRef; + + if (imgElement == null) throw new UiLogicError("Image element is null."); + + const moveRatio = { + x: movement.x / imgElement.width, + y: movement.y / imgElement.height, + }; + + const newRatio = { + x: oldClip.left + moveRatio.x, + y: oldClip.top + moveRatio.y, + }; + if (newRatio.x < 0) { + newRatio.x = 0; + } else if (newRatio.x > 1 - oldClip.width) { + newRatio.x = 1 - oldClip.width; + } + if (newRatio.y < 0) { + newRatio.y = 0; + } else if (newRatio.y > 1 - oldClip.height) { + newRatio.y = 1 - oldClip.height; + } + + onChange({ left: newRatio.x, top: newRatio.y, width: oldClip.width }); + }, + [oldState, onChange] + ); + + const onHandlerPointerMove = React.useCallback( + (e: React.PointerEvent) => { + if (oldState == null) return; + + const oldClip = oldState.clip; + + const movement = { x: e.clientX - oldState.x, y: e.clientY - oldState.y }; + + const ratio = imageInfo == null ? 1 : imageInfo.ratio; + + const { current: imgElement } = imgElementRef; + + if (imgElement == null) throw new UiLogicError("Image element is null."); + + const moveRatio = { + x: movement.x / imgElement.width, + y: movement.x / imgElement.width / ratio, + }; + + const newRatio = { + x: oldClip.width + moveRatio.x, + y: oldClip.height + moveRatio.y, + }; + + const maxRatio = { + x: Math.min(1 - oldClip.left, newRatio.x), + y: Math.min(1 - oldClip.top, newRatio.y), + }; + + const maxWidthRatio = Math.min(maxRatio.x, maxRatio.y * ratio); + + let newWidth; + if (newRatio.x < 0) { + newWidth = 0; + } else if (newRatio.x > maxWidthRatio) { + newWidth = maxWidthRatio; + } else { + newWidth = newRatio.x; + } + + onChange({ left: oldClip.left, top: oldClip.top, width: newWidth }); + }, + [imageInfo, oldState, onChange] + ); + + const toPercentage = (n: number): string => `${n}%`; + + // fuck!!! I just can't find a better way to implement this in pure css + const containerStyle: React.CSSProperties = (() => { + if (imageInfo == null) { + return { width: "100%", paddingTop: "100%", height: 0 }; + } else { + if (imageInfo.ratio > 1) { + return { + width: toPercentage(100 / imageInfo.ratio), + paddingTop: "100%", + height: 0, + }; + } else { + return { + width: "100%", + paddingTop: toPercentage(100 * imageInfo.ratio), + height: 0, + }; + } + } + })(); + + return ( + <div + className={clsx("image-cropper-container", className)} + style={containerStyle} + > + <img ref={onImageRef} src={imageUrl} onLoad={onImageLoad} alt="to crop" /> + <div className="image-cropper-mask-container"> + <div + className="image-cropper-mask" + touch-action="none" + style={{ + left: toPercentage(c.left * 100), + top: toPercentage(c.top * 100), + width: toPercentage(c.width * 100), + height: toPercentage(c.height * 100), + }} + onPointerMove={onPointerMove} + onPointerDown={onPointerDown} + onPointerUp={onPointerUp} + /> + </div> + <div + className="image-cropper-handler" + touch-action="none" + style={{ + left: `calc(${(c.left + c.width) * 100}% - 15px)`, + top: `calc(${(c.top + c.height) * 100}% - 15px)`, + }} + onPointerMove={onHandlerPointerMove} + onPointerDown={onPointerDown} + onPointerUp={onPointerUp} + /> + </div> + ); +}; + +export default ImageCropper; + +export function applyClipToImage( + image: HTMLImageElement, + clip: Clip, + mimeType: string +): Promise<Blob> { + return new Promise((resolve, reject) => { + const naturalSize = { + width: image.naturalWidth, + height: image.naturalHeight, + }; + const clipArea = { + x: naturalSize.width * clip.left, + y: naturalSize.height * clip.top, + length: naturalSize.width * clip.width, + }; + + const canvas = document.createElement("canvas"); + canvas.width = clipArea.length; + canvas.height = clipArea.length; + const context = canvas.getContext("2d"); + + if (context == null) throw new Error("Failed to create context."); + + context.drawImage( + image, + clipArea.x, + clipArea.y, + clipArea.length, + clipArea.length, + 0, + 0, + clipArea.length, + clipArea.length + ); + + canvas.toBlob((blob) => { + if (blob == null) { + reject(new Error("canvas.toBlob returns null")); + } else { + resolve(blob); + } + }, mimeType); + }); +} diff --git a/FrontEnd/src/app/views/common/LoadingButton.tsx b/FrontEnd/src/app/views/common/LoadingButton.tsx new file mode 100644 index 00000000..154334a7 --- /dev/null +++ b/FrontEnd/src/app/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="ml-1" + variant={variant} + animation="grow" + size="sm" + /> + ) : null} + </Button> + ); +}; + +export default LoadingButton; diff --git a/FrontEnd/src/app/views/common/LoadingPage.tsx b/FrontEnd/src/app/views/common/LoadingPage.tsx new file mode 100644 index 00000000..590fafa0 --- /dev/null +++ b/FrontEnd/src/app/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/app/views/common/OperationDialog.tsx b/FrontEnd/src/app/views/common/OperationDialog.tsx new file mode 100644 index 00000000..841392a6 --- /dev/null +++ b/FrontEnd/src/app/views/common/OperationDialog.tsx @@ -0,0 +1,364 @@ +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Form, Button, Modal } from "react-bootstrap"; + +import { UiLogicError } from "@/common"; + +import LoadingButton from "./LoadingButton"; + +interface DefaultErrorPromptProps { + error?: string; +} + +const DefaultErrorPrompt: React.FC<DefaultErrorPromptProps> = (props) => { + const { t } = useTranslation(); + + let result = <p className="text-danger">{t("operationDialog.error")}</p>; + + if (props.error != null) { + result = ( + <> + {result} + <p className="text-danger">{props.error}</p> + </> + ); + } + + return result; +}; + +export type OperationInputOptionalError = undefined | null | string; + +export interface OperationInputErrorInfo { + [index: number]: OperationInputOptionalError; +} + +export type OperationInputValidator<TValue> = ( + value: TValue, + values: (string | boolean)[] +) => OperationInputOptionalError | OperationInputErrorInfo; + +export interface OperationTextInputInfo { + type: "text"; + password?: boolean; + label?: string; + initValue?: string; + textFieldProps?: Omit< + React.InputHTMLAttributes<HTMLInputElement>, + "type" | "value" | "onChange" | "aria-relevant" + >; + helperText?: string; + validator?: OperationInputValidator<string>; +} + +export interface OperationBoolInputInfo { + type: "bool"; + label: string; + initValue?: boolean; +} + +export interface OperationSelectInputInfoOption { + value: string; + label: string; + icon?: React.ReactElement; +} + +export interface OperationSelectInputInfo { + type: "select"; + label: string; + options: OperationSelectInputInfoOption[]; + initValue?: string; +} + +export type OperationInputInfo = + | OperationTextInputInfo + | OperationBoolInputInfo + | OperationSelectInputInfo; + +interface OperationResult { + type: "success" | "failure"; + data: unknown; +} + +interface OperationDialogProps { + open: boolean; + close: () => void; + title: React.ReactNode; + titleColor?: "default" | "dangerous" | "create" | string; + onProcess: (inputs: (string | boolean)[]) => Promise<unknown>; + inputScheme?: OperationInputInfo[]; + inputPrompt?: string | (() => React.ReactNode); + processPrompt?: () => React.ReactNode; + successPrompt?: (data: unknown) => React.ReactNode; + failurePrompt?: (error: unknown) => React.ReactNode; + onSuccessAndClose?: () => void; +} + +const OperationDialog: React.FC<OperationDialogProps> = (props) => { + const inputScheme = props.inputScheme ?? []; + + const { t } = useTranslation(); + + type Step = "input" | "process" | OperationResult; + const [step, setStep] = useState<Step>("input"); + const [values, setValues] = useState<(boolean | string)[]>( + inputScheme.map((i) => { + if (i.type === "bool") { + return i.initValue ?? false; + } else if (i.type === "text" || i.type === "select") { + return i.initValue ?? ""; + } else { + throw new UiLogicError("Unknown input scheme."); + } + }) + ); + const [inputError, setInputError] = useState<OperationInputErrorInfo>({}); + + const close = (): void => { + if (step !== "process") { + props.close(); + if ( + typeof step === "object" && + step.type === "success" && + props.onSuccessAndClose + ) { + props.onSuccessAndClose(); + } + } else { + console.log("Attempt to close modal when processing."); + } + }; + + const onConfirm = (): void => { + setStep("process"); + props.onProcess(values).then( + (d: unknown) => { + setStep({ + type: "success", + data: d, + }); + }, + (e: unknown) => { + setStep({ + type: "failure", + data: e, + }); + } + ); + }; + + let body: React.ReactNode; + if (step === "input" || step === "process") { + const process = step === "process"; + + let inputPrompt = + typeof props.inputPrompt === "function" + ? props.inputPrompt() + : props.inputPrompt; + inputPrompt = <h6>{inputPrompt}</h6>; + + const updateValue = ( + index: number, + newValue: string | boolean + ): (string | boolean)[] => { + const oldValues = values; + const newValues = oldValues.slice(); + newValues[index] = newValue; + setValues(newValues); + return newValues; + }; + + const testErrorInfo = (errorInfo: OperationInputErrorInfo): boolean => { + for (let i = 0; i < inputScheme.length; i++) { + if (inputScheme[i].type === "text" && errorInfo[i] != null) { + return true; + } + } + return false; + }; + + const calculateError = ( + oldError: OperationInputErrorInfo, + index: number, + newError: OperationInputOptionalError | OperationInputErrorInfo + ): OperationInputErrorInfo => { + if (newError === undefined) { + return oldError; + } else if (newError === null || typeof newError === "string") { + return { ...oldError, [index]: newError }; + } else { + const newInputError: OperationInputErrorInfo = { ...oldError }; + for (const [index, error] of Object.entries(newError)) { + if (error !== undefined) { + newInputError[+index] = error as OperationInputOptionalError; + } + } + return newInputError; + } + }; + + const validateAll = (): boolean => { + let newInputError = inputError; + for (let i = 0; i < inputScheme.length; i++) { + const item = inputScheme[i]; + if (item.type === "text") { + newInputError = calculateError( + newInputError, + i, + item.validator?.(values[i] as string, values) + ); + } + } + const result = !testErrorInfo(newInputError); + setInputError(newInputError); + return result; + }; + + body = ( + <> + <Modal.Body> + {inputPrompt} + {inputScheme.map((item, index) => { + const value = values[index]; + const error: string | undefined = ((e) => + typeof e === "string" ? t(e) : undefined)(inputError?.[index]); + + if (item.type === "text") { + return ( + <Form.Group key={index}> + {item.label && <Form.Label>{t(item.label)}</Form.Label>} + <Form.Control + type={item.password === true ? "password" : "text"} + value={value as string} + onChange={(e) => { + const v = e.target.value; + const newValues = updateValue(index, v); + setInputError( + calculateError( + inputError, + index, + item.validator?.(v, newValues) + ) + ); + }} + isInvalid={error != null} + disabled={process} + /> + {error != null && ( + <Form.Control.Feedback type="invalid"> + {error} + </Form.Control.Feedback> + )} + {item.helperText && ( + <Form.Text>{t(item.helperText)}</Form.Text> + )} + </Form.Group> + ); + } else if (item.type === "bool") { + return ( + <Form.Group key={index}> + <Form.Check<"input"> + type="checkbox" + checked={value as boolean} + onChange={(event) => { + updateValue(index, event.currentTarget.checked); + }} + label={t(item.label)} + disabled={process} + /> + </Form.Group> + ); + } else if (item.type === "select") { + return ( + <Form.Group key={index}> + <Form.Label>{t(item.label)}</Form.Label> + <Form.Control + as="select" + value={value as string} + onChange={(event) => { + updateValue(index, event.target.value); + }} + disabled={process} + > + {item.options.map((option, i) => { + return ( + <option value={option.value} key={i}> + {option.icon} + {t(option.label)} + </option> + ); + })} + </Form.Control> + </Form.Group> + ); + } + })} + </Modal.Body> + <Modal.Footer> + <Button variant="outline-secondary" onClick={close}> + {t("operationDialog.cancel")} + </Button> + <LoadingButton + variant="primary" + loading={process} + disabled={testErrorInfo(inputError)} + onClick={() => { + if (validateAll()) { + onConfirm(); + } + }} + > + {t("operationDialog.confirm")} + </LoadingButton> + </Modal.Footer> + </> + ); + } else { + let content: React.ReactNode; + const result = step; + if (result.type === "success") { + content = + props.successPrompt?.(result.data) ?? t("operationDialog.success"); + if (typeof content === "string") + content = <p className="text-success">{content}</p>; + } else { + content = props.failurePrompt?.(result.data) ?? <DefaultErrorPrompt />; + if (typeof content === "string") + content = <DefaultErrorPrompt error={content} />; + } + body = ( + <> + <Modal.Body>{content}</Modal.Body> + <Modal.Footer> + <Button variant="primary" onClick={close}> + {t("operationDialog.ok")} + </Button> + </Modal.Footer> + </> + ); + } + + const title = typeof props.title === "string" ? t(props.title) : props.title; + + return ( + <Modal show={props.open} onHide={close}> + <Modal.Header + className={ + props.titleColor != null + ? "text-" + + (props.titleColor === "create" + ? "success" + : props.titleColor === "dangerous" + ? "danger" + : props.titleColor) + : undefined + } + > + {title} + </Modal.Header> + {body} + </Modal> + ); +}; + +export default OperationDialog; diff --git a/FrontEnd/src/app/views/common/SearchInput.tsx b/FrontEnd/src/app/views/common/SearchInput.tsx new file mode 100644 index 00000000..9833d515 --- /dev/null +++ b/FrontEnd/src/app/views/common/SearchInput.tsx @@ -0,0 +1,63 @@ +import React, { useCallback } from "react"; +import clsx from "clsx"; +import { useTranslation } from "react-i18next"; +import { Spinner, Form, Button } from "react-bootstrap"; + +export interface SearchInputProps { + value: string; + onChange: (value: string) => void; + onButtonClick: () => void; + className?: string; + loading?: boolean; + buttonText?: string; + placeholder?: string; + additionalButton?: React.ReactNode; +} + +const SearchInput: React.FC<SearchInputProps> = (props) => { + const { onChange, onButtonClick } = props; + + const { t } = useTranslation(); + + const onInputChange = useCallback( + (event: React.ChangeEvent<HTMLInputElement>): void => { + onChange(event.currentTarget.value); + }, + [onChange] + ); + + const onInputKeyPress = useCallback( + (event: React.KeyboardEvent<HTMLInputElement>): void => { + if (event.key === "Enter") { + onButtonClick(); + } + }, + [onButtonClick] + ); + + return ( + <Form inline className={clsx("my-2", props.className)}> + <Form.Control + className="mr-sm-2 flex-grow-1" + value={props.value} + onChange={onInputChange} + onKeyPress={onInputKeyPress} + placeholder={props.placeholder} + /> + <div className="mt-2 mt-sm-0 order-sm-last ml-sm-3"> + {props.additionalButton} + </div> + <div className="mt-2 mt-sm-0 ml-auto ml-sm-0"> + {props.loading ? ( + <Spinner variant="primary" animation="border" /> + ) : ( + <Button variant="outline-primary" onClick={props.onButtonClick}> + {props.buttonText ?? t("search")} + </Button> + )} + </div> + </Form> + ); +}; + +export default SearchInput; diff --git a/FrontEnd/src/app/views/common/TimelineLogo.tsx b/FrontEnd/src/app/views/common/TimelineLogo.tsx new file mode 100644 index 00000000..27d188fc --- /dev/null +++ b/FrontEnd/src/app/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/app/views/common/UserTimelineLogo.tsx b/FrontEnd/src/app/views/common/UserTimelineLogo.tsx new file mode 100644 index 00000000..29f6a69f --- /dev/null +++ b/FrontEnd/src/app/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={color}> + <circle cx="85" cy="75" r="10" /> + <path d="m70,100c0,0 15,-30 30,0.25" /> + </g> + </svg> + ); +}; + +export default UserTimelineLogo; diff --git a/FrontEnd/src/app/views/common/alert/AlertHost.tsx b/FrontEnd/src/app/views/common/alert/AlertHost.tsx new file mode 100644 index 00000000..c74f18e2 --- /dev/null +++ b/FrontEnd/src/app/views/common/alert/AlertHost.tsx @@ -0,0 +1,101 @@ +import React, { useCallback } from "react"; +import without from "lodash/without"; +import concat from "lodash/concat"; +import { useTranslation } from "react-i18next"; +import { Alert } from "react-bootstrap"; + +import { + alertService, + AlertInfoEx, + kAlertHostId, + AlertInfo, +} from "@/services/alert"; + +interface AutoCloseAlertProps { + alert: AlertInfo; + close: () => void; +} + +export const AutoCloseAlert: React.FC<AutoCloseAlertProps> = (props) => { + const { alert } = props; + const { dismissTime } = alert; + + const { t } = useTranslation(); + + React.useEffect(() => { + const tag = + dismissTime === "never" + ? null + : typeof dismissTime === "number" + ? window.setTimeout(props.close, dismissTime) + : window.setTimeout(props.close, 5000); + return () => { + if (tag != null) { + window.clearTimeout(tag); + } + }; + }, [dismissTime, props.close]); + + return ( + <Alert + className="m-3" + variant={alert.type ?? "primary"} + onClose={props.close} + dismissible + > + {(() => { + const { message } = alert; + if (typeof message === "function") { + const Message = message; + return <Message />; + } else if (typeof message === "object" && message.type === "i18n") { + return t(message.key); + } else return alert.message; + })()} + </Alert> + ); +}; + +// oh what a bad name! +interface AlertInfoExEx extends AlertInfoEx { + close: () => void; +} + +const AlertHost: React.FC = () => { + const [alerts, setAlerts] = React.useState<AlertInfoExEx[]>([]); + + // react guarantee that state setters are stable, so we don't need to add it to dependency list + + const consume = useCallback((alert: AlertInfoEx): void => { + const alertEx: AlertInfoExEx = { + ...alert, + close: () => { + setAlerts((oldAlerts) => { + return without(oldAlerts, alertEx); + }); + }, + }; + setAlerts((oldAlerts) => { + return concat(oldAlerts, alertEx); + }); + }, []); + + React.useEffect(() => { + alertService.registerConsumer(consume); + return () => { + alertService.unregisterConsumer(consume); + }; + }, [consume]); + + return ( + <div id={kAlertHostId} className="alert-container"> + {alerts.map((alert) => { + return ( + <AutoCloseAlert key={alert.id} alert={alert} close={alert.close} /> + ); + })} + </div> + ); +}; + +export default AlertHost; diff --git a/FrontEnd/src/app/views/common/alert/alert.sass b/FrontEnd/src/app/views/common/alert/alert.sass new file mode 100644 index 00000000..c3560b87 --- /dev/null +++ b/FrontEnd/src/app/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/app/views/common/common.sass b/FrontEnd/src/app/views/common/common.sass new file mode 100644 index 00000000..78e6fd14 --- /dev/null +++ b/FrontEnd/src/app/views/common/common.sass @@ -0,0 +1,33 @@ +.image-cropper-container
+ position: relative
+ box-sizing: border-box
+ user-select: none
+
+.image-cropper-container img
+ position: absolute
+ left: 0
+ top: 0
+ width: 100%
+ height: 100%
+
+.image-cropper-mask-container
+ position: absolute
+ left: 0
+ top: 0
+ right: 0
+ bottom: 0
+ overflow: hidden
+
+.image-cropper-mask
+ position: absolute
+ box-shadow: 0 0 0 10000px rgba(255, 255, 255, 80%)
+ touch-action: none
+
+.image-cropper-handler
+ position: absolute
+ width: 26px
+ height: 26px
+ border: black solid 2px
+ border-radius: 50%
+ background: white
+ touch-action: none
diff --git a/FrontEnd/src/app/views/home/BoardWithUser.tsx b/FrontEnd/src/app/views/home/BoardWithUser.tsx new file mode 100644 index 00000000..dcd39cbe --- /dev/null +++ b/FrontEnd/src/app/views/home/BoardWithUser.tsx @@ -0,0 +1,101 @@ +import React from "react"; +import { Row, Col } from "react-bootstrap"; +import { useTranslation } from "react-i18next"; + +import { UserWithToken } from "@/services/user"; +import { TimelineInfo } from "@/services/timeline"; +import { getHttpTimelineClient } from "@/http/timeline"; + +import TimelineBoard from "./TimelineBoard"; +import OfflineBoard from "./OfflineBoard"; + +const BoardWithUser: React.FC<{ user: UserWithToken }> = ({ user }) => { + const { t } = useTranslation(); + + const [ownTimelines, setOwnTimelines] = React.useState< + TimelineInfo[] | "offline" | "loading" + >("loading"); + const [joinTimelines, setJoinTimelines] = React.useState< + TimelineInfo[] | "offline" | "loading" + >("loading"); + + React.useEffect(() => { + let subscribe = true; + if (ownTimelines === "loading") { + void getHttpTimelineClient() + .listTimeline({ relate: user.username, relateType: "own" }) + .then( + (timelines) => { + if (subscribe) { + setOwnTimelines(timelines); + } + }, + () => { + setOwnTimelines("offline"); + } + ); + } + return () => { + subscribe = false; + }; + }, [user, ownTimelines]); + + React.useEffect(() => { + let subscribe = true; + if (joinTimelines === "loading") { + void getHttpTimelineClient() + .listTimeline({ relate: user.username, relateType: "join" }) + .then( + (timelines) => { + if (subscribe) { + setJoinTimelines(timelines); + } + }, + () => { + setJoinTimelines("offline"); + } + ); + } + return () => { + subscribe = false; + }; + }, [user, joinTimelines]); + + return ( + <Row className="my-2 justify-content-center"> + {ownTimelines === "offline" && joinTimelines === "offline" ? ( + <Col className="py-2" sm="8" lg="6"> + <OfflineBoard + onReload={() => { + setOwnTimelines("loading"); + setJoinTimelines("loading"); + }} + /> + </Col> + ) : ( + <> + <Col sm="6" lg="5" className="py-2"> + <TimelineBoard + title={t("home.ownTimeline")} + timelines={ownTimelines} + onReload={() => { + setOwnTimelines("loading"); + }} + /> + </Col> + <Col sm="6" lg="5" className="py-2"> + <TimelineBoard + title={t("home.joinTimeline")} + timelines={joinTimelines} + onReload={() => { + setJoinTimelines("loading"); + }} + /> + </Col> + </> + )} + </Row> + ); +}; + +export default BoardWithUser; diff --git a/FrontEnd/src/app/views/home/BoardWithoutUser.tsx b/FrontEnd/src/app/views/home/BoardWithoutUser.tsx new file mode 100644 index 00000000..ebfddb50 --- /dev/null +++ b/FrontEnd/src/app/views/home/BoardWithoutUser.tsx @@ -0,0 +1,60 @@ +import React from "react"; +import { Row, Col } from "react-bootstrap"; + +import { TimelineInfo } from "@/services/timeline"; +import { getHttpTimelineClient } from "@/http/timeline"; + +import TimelineBoard from "./TimelineBoard"; +import OfflineBoard from "./OfflineBoard"; + +const BoardWithoutUser: React.FC = () => { + const [publicTimelines, setPublicTimelines] = React.useState< + TimelineInfo[] | "offline" | "loading" + >("loading"); + + React.useEffect(() => { + let subscribe = true; + if (publicTimelines === "loading") { + void getHttpTimelineClient() + .listTimeline({ visibility: "Public" }) + .then( + (timelines) => { + if (subscribe) { + setPublicTimelines(timelines); + } + }, + () => { + setPublicTimelines("offline"); + } + ); + } + return () => { + subscribe = false; + }; + }, [publicTimelines]); + + return ( + <Row className="my-2 justify-content-center"> + {publicTimelines === "offline" ? ( + <Col sm="8" lg="6"> + <OfflineBoard + onReload={() => { + setPublicTimelines("loading"); + }} + /> + </Col> + ) : ( + <Col sm="8" lg="6"> + <TimelineBoard + timelines={publicTimelines} + onReload={() => { + setPublicTimelines("loading"); + }} + /> + </Col> + )} + </Row> + ); +}; + +export default BoardWithoutUser; diff --git a/FrontEnd/src/app/views/home/OfflineBoard.tsx b/FrontEnd/src/app/views/home/OfflineBoard.tsx new file mode 100644 index 00000000..fc05bd74 --- /dev/null +++ b/FrontEnd/src/app/views/home/OfflineBoard.tsx @@ -0,0 +1,61 @@ +import React from "react"; +import { Link } from "react-router-dom"; +import { Trans } from "react-i18next"; + +import { getAllCachedTimelineNames } from "@/services/timeline"; +import UserTimelineLogo from "../common/UserTimelineLogo"; +import TimelineLogo from "../common/TimelineLogo"; + +export interface OfflineBoardProps { + onReload: () => void; +} + +const OfflineBoard: React.FC<OfflineBoardProps> = ({ onReload }) => { + const [timelines, setTimelines] = React.useState<string[]>([]); + + React.useEffect(() => { + let subscribe = true; + void getAllCachedTimelineNames().then((t) => { + if (subscribe) setTimelines(t); + }); + return () => { + subscribe = false; + }; + }); + + return ( + <> + <Trans i18nKey="home.offlinePrompt"> + 0 + <a + href="#" + onClick={(e) => { + onReload(); + e.preventDefault(); + }} + > + 1 + </a> + 2 + </Trans> + {timelines.map((timeline) => { + const isPersonal = timeline.startsWith("@"); + const url = isPersonal + ? `/users/${timeline.slice(1)}` + : `/timelines/${timeline}`; + return ( + <div key={timeline} className="timeline-board-item"> + {isPersonal ? ( + <UserTimelineLogo className="icon" /> + ) : ( + <TimelineLogo className="icon" /> + )} + <Link to={url}>{timeline}</Link> + </div> + ); + })} + </> + ); +}; + +export default OfflineBoard; diff --git a/FrontEnd/src/app/views/home/TimelineBoard.tsx b/FrontEnd/src/app/views/home/TimelineBoard.tsx new file mode 100644 index 00000000..a3d176e1 --- /dev/null +++ b/FrontEnd/src/app/views/home/TimelineBoard.tsx @@ -0,0 +1,73 @@ +import React from "react"; +import clsx from "clsx"; +import { Link } from "react-router-dom"; +import { Trans } from "react-i18next"; +import { Spinner } from "react-bootstrap"; + +import { TimelineInfo } from "@/services/timeline"; +import TimelineLogo from "../common/TimelineLogo"; +import UserTimelineLogo from "../common/UserTimelineLogo"; + +export interface TimelineBoardProps { + title?: string; + timelines: TimelineInfo[] | "offline" | "loading"; + onReload: () => void; + className?: string; +} + +const TimelineBoard: React.FC<TimelineBoardProps> = (props) => { + const { title, timelines, className } = props; + + return ( + <div className={clsx("timeline-board", className)}> + {title != null && <h3 className="text-center">{title}</h3>} + {(() => { + if (timelines === "loading") { + return ( + <div className="d-flex flex-grow-1 justify-content-center align-items-center"> + <Spinner variant="primary" animation="border" /> + </div> + ); + } else if (timelines === "offline") { + return ( + <div className="d-flex flex-grow-1 justify-content-center align-items-center"> + <Trans i18nKey="loadFailReload" parent="div"> + 0 + <a + href="#" + onClick={(e) => { + props.onReload(); + e.preventDefault(); + }} + > + 1 + </a> + 2 + </Trans> + </div> + ); + } else { + return timelines.map((timeline) => { + const { name } = timeline; + const isPersonal = name.startsWith("@"); + const url = isPersonal + ? `/users/${timeline.owner.username}` + : `/timelines/${name}`; + return ( + <div key={name} className="timeline-board-item"> + {isPersonal ? ( + <UserTimelineLogo className="icon" /> + ) : ( + <TimelineLogo className="icon" /> + )} + <Link to={url}>{name}</Link> + </div> + ); + }); + } + })()} + </div> + ); +}; + +export default TimelineBoard; diff --git a/FrontEnd/src/app/views/home/TimelineCreateDialog.tsx b/FrontEnd/src/app/views/home/TimelineCreateDialog.tsx new file mode 100644 index 00000000..d9467719 --- /dev/null +++ b/FrontEnd/src/app/views/home/TimelineCreateDialog.tsx @@ -0,0 +1,53 @@ +import React from "react"; +import { useHistory } from "react-router"; + +import { validateTimelineName, timelineService } from "@/services/timeline"; +import OperationDialog from "../common/OperationDialog"; + +interface TimelineCreateDialogProps { + open: boolean; + close: () => void; +} + +const TimelineCreateDialog: React.FC<TimelineCreateDialogProps> = (props) => { + const history = useHistory(); + + let nameSaved: string; + + return ( + <OperationDialog + open={props.open} + close={props.close} + titleColor="success" + title="home.createDialog.title" + inputScheme={[ + { + type: "text", + label: "home.createDialog.name", + helperText: "home.createDialog.nameFormat", + validator: (name) => { + if (name.length === 0) { + return "home.createDialog.noEmpty"; + } else if (name.length > 26) { + return "home.createDialog.tooLong"; + } else if (!validateTimelineName(name)) { + return "home.createDialog.badFormat"; + } else { + return null; + } + }, + }, + ]} + onProcess={([name]) => { + nameSaved = name as string; + return timelineService.createTimeline(nameSaved).toPromise(); + }} + onSuccessAndClose={() => { + history.push(`timelines/${nameSaved}`); + }} + failurePrompt={(e) => `${e as string}`} + /> + ); +}; + +export default TimelineCreateDialog; diff --git a/FrontEnd/src/app/views/home/home.sass b/FrontEnd/src/app/views/home/home.sass new file mode 100644 index 00000000..28a2e5f3 --- /dev/null +++ b/FrontEnd/src/app/views/home/home.sass @@ -0,0 +1,13 @@ +.timeline-board-item
+ font-size: 1.1em
+ @extend .my-2
+ .icon
+ height: 1.3em
+ @extend .mr-2
+
+.timeline-board
+ @extend .cru-card
+ @extend .d-flex
+ @extend .flex-column
+ @extend .p-3
+ min-height: 200px
diff --git a/FrontEnd/src/app/views/home/index.tsx b/FrontEnd/src/app/views/home/index.tsx new file mode 100644 index 00000000..760adcea --- /dev/null +++ b/FrontEnd/src/app/views/home/index.tsx @@ -0,0 +1,99 @@ +import React from "react"; +import { useHistory } from "react-router"; +import { useTranslation } from "react-i18next"; +import { Row, Container, Button, Col } from "react-bootstrap"; + +import { useUser } from "@/services/user"; +import SearchInput from "../common/SearchInput"; + +import BoardWithoutUser from "./BoardWithoutUser"; +import BoardWithUser from "./BoardWithUser"; +import TimelineCreateDialog from "./TimelineCreateDialog"; + +const HomePage: React.FC = () => { + const history = useHistory(); + + const { t } = useTranslation(); + + const user = useUser(); + + const [navText, setNavText] = React.useState<string>(""); + + const [dialog, setDialog] = React.useState<"create" | null>(null); + + const goto = React.useCallback((): void => { + if (navText === "") { + history.push("users/crupest"); + } else if (navText.startsWith("@")) { + history.push(`users/${navText.slice(1)}`); + } else { + history.push(`timelines/${navText}`); + } + }, [navText, history]); + + return ( + <> + <Container fluid> + <Row className="justify-content-center"> + <Col xs={12} sm={10} md={8} lg={6}> + <SearchInput + className="justify-content-center" + value={navText} + onChange={setNavText} + onButtonClick={goto} + buttonText={t("home.go")} + placeholder="@crupest" + additionalButton={ + user != null && ( + <Button + variant="outline-success" + onClick={() => { + setDialog("create"); + }} + > + {t("home.createButton")} + </Button> + ) + } + /> + </Col> + </Row> + {(() => { + if (user == null) { + return <BoardWithoutUser />; + } else { + return <BoardWithUser user={user} />; + } + })()} + </Container> + <footer className="text-right"> + <a + className="mx-3 text-muted" + href="http://beian.miit.gov.cn/" + target="_blank" + rel="noopener noreferrer" + > + <small>鄂ICP备18030913号-1</small> + </a> + <a + className="mx-3 text-muted" + href="http://www.beian.gov.cn/" + target="_blank" + rel="noopener noreferrer" + > + <small className="white-space-no-wrap">公安备案 42112102000124</small> + </a> + </footer> + {dialog === "create" && ( + <TimelineCreateDialog + open + close={() => { + setDialog(null); + }} + /> + )} + </> + ); +}; + +export default HomePage; diff --git a/FrontEnd/src/app/views/login/index.tsx b/FrontEnd/src/app/views/login/index.tsx new file mode 100644 index 00000000..61b9a525 --- /dev/null +++ b/FrontEnd/src/app/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 className="mt-appbar">{t("login.alreadyLogin")}</p> + </> + ); + } + + const submit = (): void => { + if (username === "" || password === "") { + setUsernameDirty(true); + setPasswordDirty(true); + return; + } + + setProcess(true); + userService + .login( + { + username: username, + password: password, + }, + rememberMe + ) + .then( + () => { + if (history.length === 0) { + history.push("/"); + } else { + history.goBack(); + } + }, + (e: Error) => { + setProcess(false); + setError(e.message); + } + ); + }; + + const onEnterPressInPassword: React.KeyboardEventHandler = (e) => { + if (e.key === "Enter") { + submit(); + } + }; + + return ( + <Container fluid className="login-container mt-2"> + <h1 className="text-center">{t("welcome")}</h1> + <Form> + <Form.Group> + <Form.Label htmlFor="username">{t("user.username")}</Form.Label> + <Form.Control + id="username" + disabled={process} + onChange={(e) => { + setUsername(e.target.value); + setUsernameDirty(true); + }} + value={username} + isInvalid={usernameDirty && username === ""} + /> + {usernameDirty && username === "" && ( + <Form.Control.Feedback type="invalid"> + {t("login.emptyUsername")} + </Form.Control.Feedback> + )} + </Form.Group> + <Form.Group> + <Form.Label htmlFor="password">{t("user.password")}</Form.Label> + <Form.Control + id="password" + type="password" + disabled={process} + onChange={(e) => { + setPassword(e.target.value); + setPasswordDirty(true); + }} + value={password} + onKeyDown={onEnterPressInPassword} + isInvalid={passwordDirty && password === ""} + /> + {passwordDirty && password === "" && ( + <Form.Control.Feedback type="invalid"> + {t("login.emptyPassword")} + </Form.Control.Feedback> + )} + </Form.Group> + <Form.Group> + <Form.Check<"input"> + id="remember-me" + type="checkbox" + checked={rememberMe} + onChange={(e) => { + setRememberMe(e.currentTarget.checked); + }} + label={t("user.rememberMe")} + /> + </Form.Group> + {error ? <p className="text-danger">{t(error)}</p> : null} + <div className="text-right"> + <LoadingButton + loading={process} + variant="primary" + onClick={(e) => { + submit(); + e.preventDefault(); + }} + disabled={username === "" || password === "" ? true : undefined} + > + {t("user.login")} + </LoadingButton> + </div> + </Form> + </Container> + ); +}; + +export default LoginPage; diff --git a/FrontEnd/src/app/views/login/login.sass b/FrontEnd/src/app/views/login/login.sass new file mode 100644 index 00000000..0bf385f5 --- /dev/null +++ b/FrontEnd/src/app/views/login/login.sass @@ -0,0 +1,2 @@ +.login-container
+ max-width: 600px
diff --git a/FrontEnd/src/app/views/settings/index.tsx b/FrontEnd/src/app/views/settings/index.tsx new file mode 100644 index 00000000..964e7442 --- /dev/null +++ b/FrontEnd/src/app/views/settings/index.tsx @@ -0,0 +1,209 @@ +import React, { useState } from "react"; +import { useHistory } from "react-router"; +import { useTranslation } from "react-i18next"; +import { Form, Container, Row, Col, Button, Modal } from "react-bootstrap"; + +import { useUser, userService } from "@/services/user"; +import OperationDialog, { + OperationInputErrorInfo, +} from "../common/OperationDialog"; + +interface ChangePasswordDialogProps { + open: boolean; + close: () => void; +} + +const ChangePasswordDialog: React.FC<ChangePasswordDialogProps> = (props) => { + const history = useHistory(); + const { t } = useTranslation(); + + const [redirect, setRedirect] = useState<boolean>(false); + + return ( + <OperationDialog + open={props.open} + title={t("settings.dialogChangePassword.title")} + titleColor="dangerous" + inputPrompt={t("settings.dialogChangePassword.prompt")} + inputScheme={[ + { + type: "text", + label: t("settings.dialogChangePassword.inputOldPassword"), + password: true, + validator: (v) => + v === "" + ? "settings.dialogChangePassword.errorEmptyOldPassword" + : null, + }, + { + type: "text", + label: t("settings.dialogChangePassword.inputNewPassword"), + password: true, + validator: (v, values) => { + const error: OperationInputErrorInfo = {}; + error[1] = + v === "" + ? "settings.dialogChangePassword.errorEmptyNewPassword" + : null; + if (v === values[2]) { + error[2] = null; + } else { + if (values[2] !== "") { + error[2] = "settings.dialogChangePassword.errorRetypeNotMatch"; + } + } + return error; + }, + }, + { + type: "text", + label: t("settings.dialogChangePassword.inputRetypeNewPassword"), + password: true, + validator: (v, values) => + v !== values[1] + ? "settings.dialogChangePassword.errorRetypeNotMatch" + : null, + }, + ]} + onProcess={async ([oldPassword, newPassword]) => { + await userService + .changePassword(oldPassword as string, newPassword as string) + .toPromise(); + await userService.logout(); + setRedirect(true); + }} + close={() => { + props.close(); + if (redirect) { + history.push("/login"); + } + }} + /> + ); +}; + +const ConfirmLogoutDialog: React.FC<{ + toggle: () => void; + onConfirm: () => void; +}> = ({ toggle, onConfirm }) => { + const { t } = useTranslation(); + + return ( + <Modal show centered onHide={toggle}> + <Modal.Header> + <Modal.Title className="text-danger"> + {t("settings.dialogConfirmLogout.title")} + </Modal.Title> + </Modal.Header> + <Modal.Body>{t("settings.dialogConfirmLogout.prompt")}</Modal.Body> + <Modal.Footer> + <Button variant="secondary" onClick={toggle}> + {t("operationDialog.cancel")} + </Button> + <Button variant="danger" onClick={onConfirm}> + {t("operationDialog.confirm")} + </Button> + </Modal.Footer> + </Modal> + ); +}; + +const SettingsPage: React.FC = (_) => { + const { i18n, t } = useTranslation(); + const user = useUser(); + const history = useHistory(); + + const [dialog, setDialog] = useState<null | "changepassword" | "logout">( + null + ); + + const language = i18n.language.slice(0, 2); + + return ( + <Container fluid> + {user ? ( + <> + <Row className="border-bottom p-3 cursor-pointer"> + <Col xs="12"> + <h5 + onClick={() => { + history.push(`/users/${user.username}`); + }} + > + {t("settings.gotoSelf")} + </h5> + </Col> + </Row> + <Row className="border-bottom p-3 cursor-pointer"> + <Col xs="12"> + <h5 + className="text-danger" + onClick={() => setDialog("changepassword")} + > + {t("settings.changePassword")} + </h5> + </Col> + </Row> + <Row className="border-bottom p-3 cursor-pointer"> + <Col xs="12"> + <h5 + className="text-danger" + onClick={() => { + setDialog("logout"); + }} + > + {t("settings.logout")} + </h5> + </Col> + </Row> + </> + ) : null} + <Row className="align-items-center border-bottom p-3"> + <Col xs="12" sm="auto"> + <h5>{t("settings.languagePrimary")}</h5> + <p>{t("settings.languageSecondary")}</p> + </Col> + <Col xs="auto" className="ml-auto"> + <Form.Control + as="select" + value={language} + onChange={(e) => { + void i18n.changeLanguage(e.target.value); + }} + > + <option value="zh">中文</option> + <option value="en">English</option> + </Form.Control> + </Col> + </Row> + {(() => { + switch (dialog) { + case "changepassword": + return ( + <ChangePasswordDialog + open + close={() => { + setDialog(null); + }} + /> + ); + case "logout": + return ( + <ConfirmLogoutDialog + toggle={() => setDialog(null)} + onConfirm={() => { + void userService.logout().then(() => { + history.push("/"); + }); + }} + /> + ); + default: + return null; + } + })()} + </Container> + ); +}; + +export default SettingsPage; diff --git a/FrontEnd/src/app/views/timeline-common/CollapseButton.tsx b/FrontEnd/src/app/views/timeline-common/CollapseButton.tsx new file mode 100644 index 00000000..3c52150f --- /dev/null +++ b/FrontEnd/src/app/views/timeline-common/CollapseButton.tsx @@ -0,0 +1,23 @@ +import React from "react"; +import clsx from "clsx"; +import Svg from "react-inlinesvg"; +import arrowsAngleContractIcon from "bootstrap-icons/icons/arrows-angle-contract.svg"; +import arrowsAngleExpandIcon from "bootstrap-icons/icons/arrows-angle-expand.svg"; + +const CollapseButton: React.FC<{ + collapse: boolean; + onClick: () => void; + className?: string; + style?: React.CSSProperties; +}> = ({ collapse, onClick, className, style }) => { + return ( + <Svg + src={collapse ? arrowsAngleExpandIcon : arrowsAngleContractIcon} + onClick={onClick} + className={clsx("text-primary icon-button", className)} + style={style} + /> + ); +}; + +export default CollapseButton; diff --git a/FrontEnd/src/app/views/timeline-common/InfoCardTemplate.tsx b/FrontEnd/src/app/views/timeline-common/InfoCardTemplate.tsx new file mode 100644 index 00000000..a8de20aa --- /dev/null +++ b/FrontEnd/src/app/views/timeline-common/InfoCardTemplate.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import clsx from "clsx"; + +import { TimelineCardComponentProps } from "../timeline-common/TimelinePageTemplateUI"; +import SyncStatusBadge from "../timeline-common/SyncStatusBadge"; +import CollapseButton from "../timeline-common/CollapseButton"; + +const InfoCardTemplate: React.FC< + Pick< + TimelineCardComponentProps<"">, + "collapse" | "toggleCollapse" | "syncStatus" | "className" + > & { children: React.ReactElement[] } +> = ({ collapse, toggleCollapse, syncStatus, className, children }) => { + return ( + <div className={clsx("cru-card p-2 clearfix", className)}> + <div className="float-right d-flex align-items-center"> + <SyncStatusBadge status={syncStatus} className="mr-2" /> + <CollapseButton collapse={collapse} onClick={toggleCollapse} /> + </div> + + <div style={{ display: collapse ? "none" : "block" }}>{children}</div> + </div> + ); +}; + +export default InfoCardTemplate; diff --git a/FrontEnd/src/app/views/timeline-common/SyncStatusBadge.tsx b/FrontEnd/src/app/views/timeline-common/SyncStatusBadge.tsx new file mode 100644 index 00000000..e67cfb43 --- /dev/null +++ b/FrontEnd/src/app/views/timeline-common/SyncStatusBadge.tsx @@ -0,0 +1,58 @@ +import React from "react"; +import clsx from "clsx"; +import { useTranslation } from "react-i18next"; + +import { UiLogicError } from "@/common"; + +export type TimelineSyncStatus = "syncing" | "synced" | "offline"; + +const SyncStatusBadge: React.FC<{ + status: TimelineSyncStatus; + style?: React.CSSProperties; + className?: string; +}> = ({ status, style, className }) => { + const { t } = useTranslation(); + + return ( + <div style={style} className={clsx("timeline-sync-state-badge", className)}> + {(() => { + switch (status) { + case "syncing": { + return ( + <> + <span className="timeline-sync-state-badge-pin bg-warning" /> + <span className="text-warning"> + {t("timeline.postSyncState.syncing")} + </span> + </> + ); + } + case "synced": { + return ( + <> + <span className="timeline-sync-state-badge-pin bg-success" /> + <span className="text-success"> + {t("timeline.postSyncState.synced")} + </span> + </> + ); + } + case "offline": { + return ( + <> + <span className="timeline-sync-state-badge-pin bg-danger" /> + <span className="text-danger"> + {t("timeline.postSyncState.offline")} + </span> + </> + ); + } + default: + throw new UiLogicError("Unknown sync state."); + } + })()} + </div> + ); +}; + +export default SyncStatusBadge; diff --git a/FrontEnd/src/app/views/timeline-common/Timeline.tsx b/FrontEnd/src/app/views/timeline-common/Timeline.tsx new file mode 100644 index 00000000..fd051d45 --- /dev/null +++ b/FrontEnd/src/app/views/timeline-common/Timeline.tsx @@ -0,0 +1,84 @@ +import React from "react"; +import clsx from "clsx"; + +import { TimelinePostInfo } from "@/services/timeline"; + +import TimelineItem from "./TimelineItem"; + +export interface TimelinePostInfoEx extends TimelinePostInfo { + deletable: boolean; +} + +export type TimelineDeleteCallback = (index: number, id: number) => void; + +export interface TimelineProps { + className?: string; + posts: TimelinePostInfoEx[]; + onDelete: TimelineDeleteCallback; + onResize?: () => void; + containerRef?: React.Ref<HTMLDivElement>; +} + +const Timeline: React.FC<TimelineProps> = (props) => { + const { posts, onDelete, onResize } = props; + + const [indexShowDeleteButton, setIndexShowDeleteButton] = React.useState< + number + >(-1); + + const onItemClick = React.useCallback(() => { + setIndexShowDeleteButton(-1); + }, []); + + const onToggleDelete = React.useMemo(() => { + return posts.map((post, i) => { + return post.deletable + ? () => { + setIndexShowDeleteButton((oldIndexShowDeleteButton) => { + return oldIndexShowDeleteButton !== i ? i : -1; + }); + } + : undefined; + }); + }, [posts]); + + const onItemDelete = React.useMemo(() => { + return posts.map((post, i) => { + return () => { + onDelete(i, post.id); + }; + }); + }, [posts, onDelete]); + + return ( + <div ref={props.containerRef} className={clsx("timeline", props.className)}> + {(() => { + const length = posts.length; + return posts.map((post, i) => { + const toggleMore = onToggleDelete[i]; + + return ( + <TimelineItem + post={post} + key={post.id} + current={length - 1 === i} + more={ + toggleMore + ? { + isOpen: indexShowDeleteButton === i, + toggle: toggleMore, + onDelete: onItemDelete[i], + } + : undefined + } + onClick={onItemClick} + onResize={onResize} + /> + ); + }); + })()} + </div> + ); +}; + +export default Timeline; diff --git a/FrontEnd/src/app/views/timeline-common/TimelineItem.tsx b/FrontEnd/src/app/views/timeline-common/TimelineItem.tsx new file mode 100644 index 00000000..4db23371 --- /dev/null +++ b/FrontEnd/src/app/views/timeline-common/TimelineItem.tsx @@ -0,0 +1,172 @@ +import React from "react"; +import clsx from "clsx"; +import { Link } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import Svg from "react-inlinesvg"; +import chevronDownIcon from "bootstrap-icons/icons/chevron-down.svg"; +import trashIcon from "bootstrap-icons/icons/trash.svg"; +import { Modal, Button } from "react-bootstrap"; + +import { useAvatar } from "@/services/user"; +import { TimelinePostInfo } from "@/services/timeline"; + +import BlobImage from "../common/BlobImage"; + +const TimelinePostDeleteConfirmDialog: React.FC<{ + toggle: () => void; + onConfirm: () => void; +}> = ({ toggle, onConfirm }) => { + const { t } = useTranslation(); + + return ( + <Modal toggle={toggle} isOpen centered> + <Modal.Header> + <Modal.Title className="text-danger"> + {t("timeline.post.deleteDialog.title")} + </Modal.Title> + </Modal.Header> + <Modal.Body>{t("timeline.post.deleteDialog.prompt")}</Modal.Body> + <Modal.Footer> + <Button variant="secondary" onClick={toggle}> + {t("operationDialog.cancel")} + </Button> + <Button + variant="danger" + onClick={() => { + onConfirm(); + toggle(); + }} + > + {t("operationDialog.confirm")} + </Button> + </Modal.Footer> + </Modal> + ); +}; + +export interface TimelineItemProps { + post: TimelinePostInfo; + current?: boolean; + more?: { + isOpen: boolean; + toggle: () => void; + onDelete: () => void; + }; + onClick?: () => void; + onResize?: () => void; + className?: string; + style?: React.CSSProperties; +} + +const TimelineItem: React.FC<TimelineItemProps> = (props) => { + const { i18n } = useTranslation(); + + const current = props.current === true; + + const { more, onResize } = props; + + const avatar = useAvatar(props.post.author.username); + + const [deleteDialog, setDeleteDialog] = React.useState<boolean>(false); + const toggleDeleteDialog = React.useCallback( + () => setDeleteDialog((old) => !old), + [] + ); + + return ( + <div + className={clsx( + "timeline-item position-relative", + current && "current", + props.className + )} + onClick={props.onClick} + style={props.style} + > + <div className="timeline-line-area-container"> + <div className="timeline-line-area"> + <div className="timeline-line-segment start"></div> + <div className="timeline-line-node-container"> + <div className="timeline-line-node"></div> + </div> + <div className="timeline-line-segment end"></div> + {current && <div className="timeline-line-segment current-end" />} + </div> + </div> + <div className="timeline-content-area"> + <div> + <span className="mr-2"> + <span className="text-primary white-space-no-wrap mr-2"> + {props.post.time.toLocaleString(i18n.languages)} + </span> + <small className="text-dark">{props.post.author.nickname}</small> + </span> + {more != null ? ( + <Svg + src={chevronDownIcon} + className="text-info icon-button" + onClick={(e) => { + more.toggle(); + e.stopPropagation(); + }} + /> + ) : null} + </div> + <div className="timeline-content"> + <Link + className="float-left m-2" + to={"/users/" + props.post.author.username} + > + <BlobImage + onLoad={onResize} + blob={avatar} + className="avatar rounded" + /> + </Link> + {(() => { + const { content } = props.post; + if (content.type === "text") { + return content.text; + } else { + return ( + <BlobImage + onLoad={onResize} + blob={content.data} + className="timeline-content-image" + /> + ); + } + })()} + </div> + </div> + {more != null && more.isOpen ? ( + <> + <div + className="position-absolute position-lt w-100 h-100 mask d-flex justify-content-center align-items-center" + onClick={more.toggle} + > + <Svg + src={trashIcon} + className="text-danger icon-button large" + onClick={(e) => { + toggleDeleteDialog(); + e.stopPropagation(); + }} + /> + </div> + {deleteDialog ? ( + <TimelinePostDeleteConfirmDialog + toggle={() => { + toggleDeleteDialog(); + more.toggle(); + }} + onConfirm={more.onDelete} + /> + ) : null} + </> + ) : null} + </div> + ); +}; + +export default TimelineItem; diff --git a/FrontEnd/src/app/views/timeline-common/TimelineMember.tsx b/FrontEnd/src/app/views/timeline-common/TimelineMember.tsx new file mode 100644 index 00000000..67a8543a --- /dev/null +++ b/FrontEnd/src/app/views/timeline-common/TimelineMember.tsx @@ -0,0 +1,211 @@ +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Container, ListGroup, Modal, Row, Col, Button } from "react-bootstrap"; + +import { User, useAvatar } from "@/services/user"; + +import SearchInput from "../common/SearchInput"; +import BlobImage from "../common/BlobImage"; + +const TimelineMemberItem: React.FC<{ + user: User; + owner: boolean; + onRemove?: (username: string) => void; +}> = ({ user, owner, onRemove }) => { + const { t } = useTranslation(); + + const avatar = useAvatar(user.username); + + return ( + <ListGroup.Item className="container"> + <Row> + <Col xs="auto"> + <BlobImage blob={avatar} className="avatar small" /> + </Col> + <Col> + <Row>{user.nickname}</Row> + <Row> + <small>{"@" + user.username}</small> + </Row> + </Col> + {(() => { + if (owner) { + return null; + } + if (onRemove == null) { + return null; + } + return ( + <Button + className="align-self-center" + variant="danger" + onClick={() => { + onRemove(user.username); + }} + > + {t("timeline.member.remove")} + </Button> + ); + })()} + </Row> + </ListGroup.Item> + ); +}; + +export interface TimelineMemberCallbacks { + onCheckUser: (username: string) => Promise<User | null>; + onAddUser: (user: User) => Promise<void>; + onRemoveUser: (username: string) => void; +} + +export interface TimelineMemberProps { + members: User[]; + edit: TimelineMemberCallbacks | null | undefined; +} + +const TimelineMember: React.FC<TimelineMemberProps> = (props) => { + const { t } = useTranslation(); + + const [userSearchText, setUserSearchText] = useState<string>(""); + const [userSearchState, setUserSearchState] = useState< + | { + type: "user"; + data: User; + } + | { type: "error"; data: string } + | { type: "loading" } + | { type: "init" } + >({ type: "init" }); + + const userSearchAvatar = useAvatar( + userSearchState.type === "user" ? userSearchState.data.username : undefined + ); + + const members = props.members; + + return ( + <Container className="px-4"> + <ListGroup className="my-3"> + {members.map((member, index) => ( + <TimelineMemberItem + key={member.username} + user={member} + owner={index === 0} + onRemove={props.edit?.onRemoveUser} + /> + ))} + </ListGroup> + {(() => { + const edit = props.edit; + if (edit != null) { + return ( + <> + <SearchInput + value={userSearchText} + onChange={(v) => { + setUserSearchText(v); + }} + loading={userSearchState.type === "loading"} + onButtonClick={() => { + if (userSearchText === "") { + setUserSearchState({ + type: "error", + data: "login.emptyUsername", + }); + return; + } + + setUserSearchState({ type: "loading" }); + edit.onCheckUser(userSearchText).then( + (u) => { + if (u == null) { + setUserSearchState({ + type: "error", + data: "timeline.userNotExist", + }); + } else { + setUserSearchState({ type: "user", data: u }); + } + }, + (e) => { + setUserSearchState({ + type: "error", + data: `${e as string}`, + }); + } + ); + }} + /> + {(() => { + if (userSearchState.type === "user") { + const u = userSearchState.data; + const addable = + members.findIndex((m) => m.username === u.username) === -1; + return ( + <> + {!addable ? ( + <p>{t("timeline.member.alreadyMember")}</p> + ) : null} + <Container className="mb-3"> + <Row> + <Col className="col-auto"> + <BlobImage + blob={userSearchAvatar} + className="avatar small" + /> + </Col> + <Col> + <Row>{u.nickname}</Row> + <Row> + <small>{"@" + u.username}</small> + </Row> + </Col> + <Button + variant="primary" + className="align-self-center" + disabled={!addable} + onClick={() => { + void edit.onAddUser(u).then((_) => { + setUserSearchText(""); + setUserSearchState({ type: "init" }); + }); + }} + > + {t("timeline.member.add")} + </Button> + </Row> + </Container> + </> + ); + } else if (userSearchState.type === "error") { + return ( + <p className="text-danger">{t(userSearchState.data)}</p> + ); + } + })()} + </> + ); + } else { + return null; + } + })()} + </Container> + ); +}; + +export default TimelineMember; + +export interface TimelineMemberDialogProps extends TimelineMemberProps { + open: boolean; + onClose: () => void; +} + +export const TimelineMemberDialog: React.FC<TimelineMemberDialogProps> = ( + props +) => { + return ( + <Modal show centered onHide={props.onClose}> + <TimelineMember {...props} /> + </Modal> + ); +}; diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx new file mode 100644 index 00000000..d5c91622 --- /dev/null +++ b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx @@ -0,0 +1,185 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { of } from "rxjs"; +import { catchError } from "rxjs/operators"; + +import { UiLogicError } from "@/common"; +import { pushAlert } from "@/services/alert"; +import { useUser, userInfoService, UserNotExistError } from "@/services/user"; +import { + timelineService, + usePostList, + useTimelineInfo, +} from "@/services/timeline"; + +import { TimelineDeleteCallback } from "./Timeline"; +import { TimelineMemberDialog } from "./TimelineMember"; +import TimelinePropertyChangeDialog from "./TimelinePropertyChangeDialog"; +import { TimelinePageTemplateUIProps } from "./TimelinePageTemplateUI"; +import { TimelinePostSendCallback } from "./TimelinePostEdit"; + +export interface TimelinePageTemplateProps<TManageItem> { + name: string; + onManage: (item: TManageItem) => void; + UiComponent: React.ComponentType< + Omit<TimelinePageTemplateUIProps<TManageItem>, "CardComponent"> + >; + notFoundI18nKey: string; +} + +export default function TimelinePageTemplate<TManageItem>( + props: TimelinePageTemplateProps<TManageItem> +): React.ReactElement | null { + const { t } = useTranslation(); + + const { name } = props; + + const service = timelineService; + + const user = useUser(); + + const [dialog, setDialog] = React.useState<null | "property" | "member">( + null + ); + + const timelineState = useTimelineInfo(name); + + const timeline = timelineState?.timeline; + + const postListState = usePostList(name); + + const error: string | undefined = (() => { + if (timelineState != null) { + const { type, timeline } = timelineState; + if (type === "offline" && timeline == null) return "Network Error"; + if (type === "synced" && timeline == null) + return t(props.notFoundI18nKey); + } + return undefined; + })(); + + const closeDialog = React.useCallback((): void => { + setDialog(null); + }, []); + + let dialogElement: React.ReactElement | undefined; + + if (dialog === "property") { + if (timeline == null) { + throw new UiLogicError( + "Timeline is null but attempt to open change property dialog." + ); + } + + dialogElement = ( + <TimelinePropertyChangeDialog + open + close={closeDialog} + oldInfo={{ + visibility: timeline.visibility, + description: timeline.description, + }} + onProcess={(req) => { + return service.changeTimelineProperty(name, req).toPromise().then(); + }} + /> + ); + } else if (dialog === "member") { + if (timeline == null) { + throw new UiLogicError( + "Timeline is null but attempt to open change property dialog." + ); + } + + dialogElement = ( + <TimelineMemberDialog + open + onClose={closeDialog} + members={[timeline.owner, ...timeline.members]} + edit={ + service.hasManagePermission(user, timeline) + ? { + onCheckUser: (u) => { + return userInfoService + .getUserInfo(u) + .pipe( + catchError((e) => { + if (e instanceof UserNotExistError) { + return of(null); + } else { + throw e; + } + }) + ) + .toPromise(); + }, + onAddUser: (u) => { + return service.addMember(name, u.username).toPromise().then(); + }, + onRemoveUser: (u) => { + service.removeMember(name, u); + }, + } + : null + } + /> + ); + } + + const { UiComponent } = props; + + const onDelete: TimelineDeleteCallback = React.useCallback( + (index, id) => { + service.deletePost(name, id).subscribe(null, () => { + pushAlert({ + type: "danger", + message: t("timeline.deletePostFailed"), + }); + }); + }, + [service, name, t] + ); + + const onPost: TimelinePostSendCallback = React.useCallback( + (req) => { + return service.createPost(name, req).toPromise().then(); + }, + [service, name] + ); + + const onManageProp = props.onManage; + + const onManage = React.useCallback( + (item: "property" | TManageItem) => { + if (item === "property") { + setDialog(item); + } else { + onManageProp(item); + } + }, + [onManageProp] + ); + + return ( + <> + <UiComponent + error={error} + timeline={timeline ?? undefined} + postListState={postListState} + onDelete={onDelete} + onPost={ + timeline != null && service.hasPostPermission(user, timeline) + ? onPost + : undefined + } + onManage={ + timeline != null && service.hasManagePermission(user, timeline) + ? onManage + : undefined + } + onMember={() => setDialog("member")} + /> + {dialogElement} + </> + ); +} diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx new file mode 100644 index 00000000..6c2c43c1 --- /dev/null +++ b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx @@ -0,0 +1,243 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { fromEvent } from "rxjs"; +import { Spinner } from "react-bootstrap"; + +import { getAlertHost } from "@/services/alert"; +import { useEventEmiiter, UiLogicError } from "@/common"; +import { + TimelineInfo, + TimelinePostsWithSyncState, + timelineService, +} from "@/services/timeline"; +import { userService } from "@/services/user"; + +import Timeline, { + TimelinePostInfoEx, + TimelineDeleteCallback, +} from "./Timeline"; +import TimelineTop from "./TimelineTop"; +import TimelinePostEdit, { TimelinePostSendCallback } from "./TimelinePostEdit"; +import { TimelineSyncStatus } from "./SyncStatusBadge"; + +export interface TimelineCardComponentProps<TManageItems> { + timeline: TimelineInfo; + onManage?: (item: TManageItems | "property") => void; + onMember: () => void; + className?: string; + collapse: boolean; + syncStatus: TimelineSyncStatus; + toggleCollapse: () => void; +} + +export interface TimelinePageTemplateUIProps<TManageItems> { + timeline?: TimelineInfo; + postListState?: TimelinePostsWithSyncState; + CardComponent: React.ComponentType<TimelineCardComponentProps<TManageItems>>; + onMember: () => void; + onManage?: (item: TManageItems | "property") => void; + onPost?: TimelinePostSendCallback; + onDelete: TimelineDeleteCallback; + error?: string; +} + +export default function TimelinePageTemplateUI<TManageItems>( + props: TimelinePageTemplateUIProps<TManageItems> +): React.ReactElement | null { + const { timeline, postListState } = props; + + const { t } = useTranslation(); + + const bottomSpaceRef = React.useRef<HTMLDivElement | null>(null); + + const onPostEditHeightChange = React.useCallback((height: number): void => { + const { current: bottomSpaceDiv } = bottomSpaceRef; + if (bottomSpaceDiv != null) { + bottomSpaceDiv.style.height = `${height}px`; + } + if (height === 0) { + const alertHost = getAlertHost(); + if (alertHost != null) { + alertHost.style.removeProperty("margin-bottom"); + } + } else { + const alertHost = getAlertHost(); + if (alertHost != null) { + alertHost.style.marginBottom = `${height}px`; + } + } + }, []); + + const timelineRef = React.useRef<HTMLDivElement | null>(null); + + const [getResizeEvent, triggerResizeEvent] = useEventEmiiter(); + + React.useEffect(() => { + const { current: timelineElement } = timelineRef; + if (timelineElement != null) { + let loadingScrollToBottom = true; + let pinBottom = false; + + const isAtBottom = (): boolean => + window.innerHeight + window.scrollY + 10 >= document.body.scrollHeight; + + const disableLoadingScrollToBottom = (): void => { + loadingScrollToBottom = false; + if (isAtBottom()) pinBottom = true; + }; + + const checkAndScrollToBottom = (): void => { + if (loadingScrollToBottom || pinBottom) { + window.scrollTo(0, document.body.scrollHeight); + } + }; + + const subscriptions = [ + fromEvent(timelineElement, "wheel").subscribe( + disableLoadingScrollToBottom + ), + fromEvent(timelineElement, "pointerdown").subscribe( + disableLoadingScrollToBottom + ), + fromEvent(timelineElement, "keydown").subscribe( + disableLoadingScrollToBottom + ), + fromEvent(window, "scroll").subscribe(() => { + if (loadingScrollToBottom) return; + + if (isAtBottom()) { + pinBottom = true; + } else { + pinBottom = false; + } + }), + fromEvent(window, "resize").subscribe(checkAndScrollToBottom), + getResizeEvent().subscribe(checkAndScrollToBottom), + ]; + + return () => { + subscriptions.forEach((s) => s.unsubscribe()); + }; + } + }, [getResizeEvent, triggerResizeEvent, timeline, postListState]); + + const genCardCollapseLocalStorageKey = (uniqueId: string): string => + `timeline.${uniqueId}.cardCollapse`; + + const cardCollapseLocalStorageKey = + timeline != null ? genCardCollapseLocalStorageKey(timeline.uniqueId) : null; + + const [cardCollapse, setCardCollapse] = React.useState<boolean>(true); + React.useEffect(() => { + if (cardCollapseLocalStorageKey != null) { + const savedCollapse = + window.localStorage.getItem(cardCollapseLocalStorageKey) === "true"; + setCardCollapse(savedCollapse); + } + }, [cardCollapseLocalStorageKey]); + + const toggleCardCollapse = (): void => { + const newState = !cardCollapse; + setCardCollapse(newState); + if (timeline != null) { + window.localStorage.setItem( + genCardCollapseLocalStorageKey(timeline.uniqueId), + newState.toString() + ); + } + }; + + let body: React.ReactElement; + + if (props.error != null) { + body = <p className="text-danger">{t(props.error)}</p>; + } else { + if (timeline != null) { + let timelineBody: React.ReactElement; + if (postListState != null) { + if (postListState.type === "notexist") { + throw new UiLogicError( + "Timeline is not null but post list state is notexist." + ); + } + if (postListState.type === "forbid") { + timelineBody = ( + <p className="text-danger">{t("timeline.messageCantSee")}</p> + ); + } else { + const posts: TimelinePostInfoEx[] = postListState.posts.map( + (post) => ({ + ...post, + deletable: timelineService.hasModifyPostPermission( + userService.currentUser, + timeline, + post + ), + }) + ); + + timelineBody = ( + <Timeline + containerRef={timelineRef} + posts={posts} + onDelete={props.onDelete} + onResize={triggerResizeEvent} + /> + ); + if (props.onPost != null) { + timelineBody = ( + <> + {timelineBody} + <div ref={bottomSpaceRef} className="flex-fix-length" /> + <TimelinePostEdit + className="fixed-bottom" + onPost={props.onPost} + onHeightChange={onPostEditHeightChange} + timelineUniqueId={timeline.uniqueId} + /> + </> + ); + } + } + } else { + timelineBody = ( + <div className="full-viewport-center-child"> + <Spinner variant="primary" animation="grow" /> + </div> + ); + } + + const { CardComponent } = props; + const syncStatus: TimelineSyncStatus = + postListState == null || postListState.syncing + ? "syncing" + : postListState.type === "synced" + ? "synced" + : "offline"; + + body = ( + <> + <CardComponent + className="timeline-template-card" + timeline={timeline} + onManage={props.onManage} + onMember={props.onMember} + syncStatus={syncStatus} + collapse={cardCollapse} + toggleCollapse={toggleCardCollapse} + /> + <TimelineTop height="56px" /> + {timelineBody} + </> + ); + } else { + body = ( + <div className="full-viewport-center-child"> + <Spinner variant="primary" animation="grow" /> + </div> + ); + } + } + + return body; +} diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePostEdit.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePostEdit.tsx new file mode 100644 index 00000000..dfa2f879 --- /dev/null +++ b/FrontEnd/src/app/views/timeline-common/TimelinePostEdit.tsx @@ -0,0 +1,241 @@ +import React from "react"; +import clsx from "clsx"; +import { useTranslation } from "react-i18next"; +import Svg from "react-inlinesvg"; +import { Button, Spinner, Row, Col, Form } from "react-bootstrap"; +import textIcon from "bootstrap-icons/icons/card-text.svg"; +import imageIcon from "bootstrap-icons/icons/image.svg"; + +import { UiLogicError } from "@/common"; + +import { pushAlert } from "@/services/alert"; +import { TimelineCreatePostRequest } from "@/services/timeline"; + +interface TimelinePostEditImageProps { + onSelect: (blob: Blob | null) => void; +} + +const TimelinePostEditImage: React.FC<TimelinePostEditImageProps> = (props) => { + const { onSelect } = props; + const { t } = useTranslation(); + + const [file, setFile] = React.useState<File | null>(null); + const [fileUrl, setFileUrl] = React.useState<string | null>(null); + const [error, setError] = React.useState<string | null>(null); + + React.useEffect(() => { + if (file != null) { + const url = URL.createObjectURL(file); + setFileUrl(url); + return () => { + URL.revokeObjectURL(url); + }; + } + }, [file]); + + const onInputChange: React.ChangeEventHandler<HTMLInputElement> = React.useCallback( + (e) => { + const files = e.target.files; + if (files == null || files.length === 0) { + setFile(null); + setFileUrl(null); + } else { + setFile(files[0]); + } + onSelect(null); + setError(null); + }, + [onSelect] + ); + + const onImgLoad = React.useCallback(() => { + onSelect(file); + }, [onSelect, file]); + + const onImgError = React.useCallback(() => { + setError("loadImageError"); + }, []); + + return ( + <> + <Form.File + label={t("chooseImage")} + onChange={onInputChange} + accept="image/*" + className="mx-3 my-1 d-inline-block" + /> + {fileUrl && error == null && ( + <img + src={fileUrl} + className="timeline-post-edit-image" + onLoad={onImgLoad} + onError={onImgError} + /> + )} + {error != null && <div className="text-danger">{t(error)}</div>} + </> + ); +}; + +export type TimelinePostSendCallback = ( + content: TimelineCreatePostRequest +) => Promise<void>; + +export interface TimelinePostEditProps { + className?: string; + onPost: TimelinePostSendCallback; + onHeightChange?: (height: number) => void; + timelineUniqueId: string; +} + +const TimelinePostEdit: React.FC<TimelinePostEditProps> = (props) => { + const { onPost } = props; + + const { t } = useTranslation(); + + const [state, setState] = React.useState<"input" | "process">("input"); + const [kind, setKind] = React.useState<"text" | "image">("text"); + const [text, setText] = React.useState<string>(""); + const [imageBlob, setImageBlob] = React.useState<Blob | null>(null); + + const draftLocalStorageKey = `timeline.${props.timelineUniqueId}.postDraft`; + + React.useEffect(() => { + setText(window.localStorage.getItem(draftLocalStorageKey) ?? ""); + }, [draftLocalStorageKey]); + + const canSend = kind === "text" || (kind === "image" && imageBlob != null); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const containerRef = React.useRef<HTMLDivElement>(null!); + + const notifyHeightChange = (): void => { + if (props.onHeightChange) { + props.onHeightChange(containerRef.current.clientHeight); + } + }; + + React.useEffect(() => { + if (props.onHeightChange) { + props.onHeightChange(containerRef.current.clientHeight); + } + return () => { + if (props.onHeightChange) { + props.onHeightChange(0); + } + }; + }); + + const toggleKind = React.useCallback(() => { + setKind((oldKind) => (oldKind === "text" ? "image" : "text")); + setImageBlob(null); + }, []); + + const onSend = React.useCallback(() => { + setState("process"); + + const req: TimelineCreatePostRequest = (() => { + switch (kind) { + case "text": + return { + content: { + type: "text", + text: text, + }, + } as TimelineCreatePostRequest; + case "image": + if (imageBlob == null) { + throw new UiLogicError( + "Content type is image but image blob is null." + ); + } + return { + content: { + type: "image", + data: imageBlob, + }, + } as TimelineCreatePostRequest; + default: + throw new UiLogicError("Unknown content type."); + } + })(); + + onPost(req).then( + (_) => { + if (kind === "text") { + setText(""); + window.localStorage.removeItem(draftLocalStorageKey); + } + setState("input"); + setKind("text"); + }, + (_) => { + pushAlert({ + type: "danger", + message: t("timeline.sendPostFailed"), + }); + setState("input"); + } + ); + }, [onPost, kind, text, imageBlob, t, draftLocalStorageKey]); + + const onImageSelect = React.useCallback((blob: Blob | null) => { + setImageBlob(blob); + }, []); + + return ( + <div + ref={containerRef} + className={clsx("container-fluid bg-light", props.className)} + > + <Row> + <Col className="px-1 py-1"> + {kind === "text" ? ( + <Form.Control + as="textarea" + className="w-100 h-100 timeline-post-edit" + value={text} + disabled={state === "process"} + onChange={(event: React.ChangeEvent<HTMLTextAreaElement>) => { + const value = event.currentTarget.value; + setText(value); + window.localStorage.setItem(draftLocalStorageKey, value); + }} + /> + ) : ( + <TimelinePostEditImage onSelect={onImageSelect} /> + )} + </Col> + <Col xs="auto" className="align-self-end m-1"> + {(() => { + if (state === "input") { + return ( + <> + <div className="d-block text-center mt-1 mb-2"> + <Svg + onLoad={notifyHeightChange} + src={kind === "text" ? imageIcon : textIcon} + className="icon-button" + onClick={toggleKind} + /> + </div> + <Button + variant="primary" + onClick={onSend} + disabled={!canSend} + > + {t("timeline.send")} + </Button> + </> + ); + } else { + return <Spinner variant="primary" animation="border" />; + } + })()} + </Col> + </Row> + </div> + ); +}; + +export default TimelinePostEdit; diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx new file mode 100644 index 00000000..87638f31 --- /dev/null +++ b/FrontEnd/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx @@ -0,0 +1,72 @@ +import React from "react"; + +import { + TimelineVisibility, + kTimelineVisibilities, + TimelineChangePropertyRequest, +} from "@/services/timeline"; + +import OperationDialog, { + OperationSelectInputInfoOption, +} from "../common/OperationDialog"; + +export interface TimelinePropertyInfo { + visibility: TimelineVisibility; + description: string; +} + +export interface TimelinePropertyChangeDialogProps { + open: boolean; + close: () => void; + oldInfo: TimelinePropertyInfo; + onProcess: (request: TimelineChangePropertyRequest) => Promise<void>; +} + +const labelMap: { [key in TimelineVisibility]: string } = { + Private: "timeline.visibility.private", + Public: "timeline.visibility.public", + Register: "timeline.visibility.register", +}; + +const TimelinePropertyChangeDialog: React.FC<TimelinePropertyChangeDialogProps> = ( + props +) => { + return ( + <OperationDialog + title={"timeline.dialogChangeProperty.title"} + titleColor="default" + inputScheme={[ + { + type: "select", + label: "timeline.dialogChangeProperty.visibility", + options: kTimelineVisibilities.map<OperationSelectInputInfoOption>( + (v) => ({ + label: labelMap[v], + value: v, + }) + ), + initValue: props.oldInfo.visibility, + }, + { + type: "text", + label: "timeline.dialogChangeProperty.description", + initValue: props.oldInfo.description, + }, + ]} + open={props.open} + close={props.close} + onProcess={([newVisibility, newDescription]) => { + const req: TimelineChangePropertyRequest = {}; + if (newVisibility !== props.oldInfo.visibility) { + req.visibility = newVisibility as TimelineVisibility; + } + if (newDescription !== props.oldInfo.description) { + req.description = newDescription as string; + } + return props.onProcess(req); + }} + /> + ); +}; + +export default TimelinePropertyChangeDialog; diff --git a/FrontEnd/src/app/views/timeline-common/TimelineTop.tsx b/FrontEnd/src/app/views/timeline-common/TimelineTop.tsx new file mode 100644 index 00000000..93a2a32c --- /dev/null +++ b/FrontEnd/src/app/views/timeline-common/TimelineTop.tsx @@ -0,0 +1,21 @@ +import React from "react"; + +export interface TimelineTopProps { + height?: number | string; + children?: React.ReactElement; +} + +const TimelineTop: React.FC<TimelineTopProps> = ({ height, children }) => { + return ( + <div style={{ height: height }} className="timeline-top"> + <div className="timeline-line-area-container"> + <div className="timeline-line-area"> + <div className="timeline-line-segment"></div> + </div> + </div> + {children} + </div> + ); +}; + +export default TimelineTop; diff --git a/FrontEnd/src/app/views/timeline-common/timeline-common.sass b/FrontEnd/src/app/views/timeline-common/timeline-common.sass new file mode 100644 index 00000000..4151bfcc --- /dev/null +++ b/FrontEnd/src/app/views/timeline-common/timeline-common.sass @@ -0,0 +1,146 @@ +@use 'sass:color' + +.timeline + z-index: 0 + position: relative + + &-item + display: flex + +$timeline-line-width: 7px +$timeline-line-node-radius: 18px +$timeline-line-color: $primary +$timeline-line-color-current: #36c2e6 + +@keyframes timeline-line-node-noncurrent + from + background: $timeline-line-color + + to + background: color.adjust($timeline-line-color, $lightness: +10%) + box-shadow: 0 0 20px 3px color.adjust($timeline-line-color, $lightness: +10%, $alpha: -0.1) + +@keyframes timeline-line-node-current + from + background: $timeline-line-color-current + + to + background: color.adjust($timeline-line-color-current, $lightness: +10%) + box-shadow: 0 0 20px 3px color.adjust($timeline-line-color-current, $lightness: +10%, $alpha: -0.1) + +.timeline-line + &-area-container + display: flex + justify-content: flex-end + padding-right: 5px + + flex: 0 0 auto + width: 60px + + &-area + display: flex + flex-direction: column + align-items: center + width: 30px + + &-segment + width: $timeline-line-width + background: $timeline-line-color + + &.start + height: 14px + flex: 0 0 auto + + &.end + flex: 1 1 auto + + &.current-end + height: 20px + flex: 0 0 auto + background: linear-gradient($timeline-line-color-current, transparent) + + &-node-container + flex: 0 0 auto + position: relative + width: $timeline-line-node-radius + height: $timeline-line-node-radius + + &-node + width: $timeline-line-node-radius + 2 + height: $timeline-line-node-radius + 2 + position: absolute + left: -1px + top: -1px + border-radius: 50% + box-sizing: border-box + z-index: 1 + animation: 1s infinite alternate + animation-name: timeline-line-node-noncurrent + +.timeline-top + display: flex + justify-content: space-between + + .timeline-line-segment + flex: 1 1 auto + +.current + .timeline-line + &-segment + + &.start + background: linear-gradient($timeline-line-color, $timeline-line-color-current) + + &.end + background: $timeline-line-color-current + + &-node + animation-name: timeline-line-node-current + +.timeline-content-area + padding: 10px 0 + flex-grow: 1 + +.timeline-item-delete-button + position: absolute + right: 0 + bottom: 0 + +.timeline-content + white-space: pre-line + +.timeline-content-image + max-width: 60% + max-height: 200px + +.timeline-post-edit-image + max-width: 100px + max-height: 100px + +.mask + background: change-color($color: white, $alpha: 0.8) + z-index: 100 + +.timeline-page-top-space + transition: height 0.5s + +.timeline-sync-state-badge + font-size: 0.8em + padding: 3px 8px + border-radius: 5px + background: #e8fbff + +.timeline-sync-state-badge-pin + display: inline-block + width: 0.4em + height: 0.4em + border-radius: 50% + vertical-align: middle + margin-right: 0.6em + +.timeline-template-card + position: fixed + z-index: 1 + top: 56px + right: 0 + margin: 0.5em diff --git a/FrontEnd/src/app/views/timeline/TimelineDeleteDialog.tsx b/FrontEnd/src/app/views/timeline/TimelineDeleteDialog.tsx new file mode 100644 index 00000000..894b8195 --- /dev/null +++ b/FrontEnd/src/app/views/timeline/TimelineDeleteDialog.tsx @@ -0,0 +1,55 @@ +import React from "react"; +import { useHistory } from "react-router"; +import { Trans } from "react-i18next"; + +import { timelineService } from "@/services/timeline"; + +import OperationDialog from "../common/OperationDialog"; + +interface TimelineDeleteDialog { + open: boolean; + name: string; + close: () => void; +} + +const TimelineDeleteDialog: React.FC<TimelineDeleteDialog> = (props) => { + const history = useHistory(); + + const { name } = props; + + return ( + <OperationDialog + open={props.open} + close={props.close} + title="timeline.deleteDialog.title" + titleColor="danger" + inputPrompt={() => { + return ( + <Trans i18nKey="timeline.deleteDialog.inputPrompt"> + 0<code className="mx-2">{{ name }}</code>2 + </Trans> + ); + }} + inputScheme={[ + { + type: "text", + validator: (value) => { + if (value !== name) { + return "timeline.deleteDialog.notMatch"; + } else { + return null; + } + }, + }, + ]} + onProcess={() => { + return timelineService.deleteTimeline(name).toPromise(); + }} + onSuccessAndClose={() => { + history.replace("/"); + }} + /> + ); +}; + +export default TimelineDeleteDialog; diff --git a/FrontEnd/src/app/views/timeline/TimelineInfoCard.tsx b/FrontEnd/src/app/views/timeline/TimelineInfoCard.tsx new file mode 100644 index 00000000..2d787709 --- /dev/null +++ b/FrontEnd/src/app/views/timeline/TimelineInfoCard.tsx @@ -0,0 +1,85 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Dropdown, Button } from "react-bootstrap"; + +import { useAvatar } from "@/services/user"; +import { timelineVisibilityTooltipTranslationMap } from "@/services/timeline"; + +import BlobImage from "../common/BlobImage"; +import { TimelineCardComponentProps } from "../timeline-common/TimelinePageTemplateUI"; +import InfoCardTemplate from "../timeline-common/InfoCardTemplate"; + +export type OrdinaryTimelineManageItem = "delete"; + +export type TimelineInfoCardProps = TimelineCardComponentProps< + OrdinaryTimelineManageItem +>; + +const TimelineInfoCard: React.FC<TimelineInfoCardProps> = (props) => { + const { + timeline, + collapse, + onMember, + onManage, + syncStatus, + toggleCollapse, + } = props; + + const { t } = useTranslation(); + + const avatar = useAvatar(timeline?.owner?.username); + + return ( + <InfoCardTemplate + className={props.className} + syncStatus={syncStatus} + collapse={collapse} + toggleCollapse={toggleCollapse} + > + <h3 className="text-primary mx-3 d-inline-block align-middle"> + {timeline.name} + </h3> + <div className="d-inline-block align-middle"> + <BlobImage blob={avatar} className="avatar small rounded-circle" /> + {timeline.owner.nickname} + <small className="ml-3 text-secondary"> + @{timeline.owner.username} + </small> + </div> + <p className="mb-0">{timeline.description}</p> + <small className="mt-1 d-block"> + {t(timelineVisibilityTooltipTranslationMap[timeline.visibility])} + </small> + <div className="text-right mt-2"> + {onManage != null ? ( + <Dropdown> + <Dropdown.Toggle variant="outline-primary"> + {t("timeline.manage")} + </Dropdown.Toggle> + <Dropdown.Menu> + <Dropdown.Item onClick={() => onManage("property")}> + {t("timeline.manageItem.property")} + </Dropdown.Item> + <Dropdown.Item onClick={onMember}> + {t("timeline.manageItem.member")} + </Dropdown.Item> + <Dropdown.Divider /> + <Dropdown.Item + className="text-danger" + onClick={() => onManage("delete")} + > + {t("timeline.manageItem.delete")} + </Dropdown.Item> + </Dropdown.Menu> + </Dropdown> + ) : ( + <Button variant="outline-primary" onClick={onMember}> + {t("timeline.memberButton")} + </Button> + )} + </div> + </InfoCardTemplate> + ); +}; + +export default TimelineInfoCard; diff --git a/FrontEnd/src/app/views/timeline/TimelinePageUI.tsx b/FrontEnd/src/app/views/timeline/TimelinePageUI.tsx new file mode 100644 index 00000000..67ea699e --- /dev/null +++ b/FrontEnd/src/app/views/timeline/TimelinePageUI.tsx @@ -0,0 +1,20 @@ +import React from "react"; + +import TimelinePageTemplateUI, { + TimelinePageTemplateUIProps, +} from "../timeline-common/TimelinePageTemplateUI"; + +import TimelineInfoCard, { + OrdinaryTimelineManageItem, +} from "./TimelineInfoCard"; + +export type TimelinePageUIProps = Omit< + TimelinePageTemplateUIProps<OrdinaryTimelineManageItem>, + "CardComponent" +>; + +const TimelinePageUI: React.FC<TimelinePageUIProps> = (props) => { + return <TimelinePageTemplateUI {...props} CardComponent={TimelineInfoCard} />; +}; + +export default TimelinePageUI; diff --git a/FrontEnd/src/app/views/timeline/index.tsx b/FrontEnd/src/app/views/timeline/index.tsx new file mode 100644 index 00000000..225a1a59 --- /dev/null +++ b/FrontEnd/src/app/views/timeline/index.tsx @@ -0,0 +1,37 @@ +import React from "react"; +import { useParams } from "react-router"; + +import TimelinePageTemplate from "../timeline-common/TimelinePageTemplate"; + +import TimelinePageUI from "./TimelinePageUI"; +import { OrdinaryTimelineManageItem } from "./TimelineInfoCard"; +import TimelineDeleteDialog from "./TimelineDeleteDialog"; + +const TimelinePage: React.FC = (_) => { + const { name } = useParams<{ name: string }>(); + + const [dialog, setDialog] = React.useState<OrdinaryTimelineManageItem | null>( + null + ); + + let dialogElement: React.ReactElement | undefined; + if (dialog === "delete") { + dialogElement = ( + <TimelineDeleteDialog open close={() => setDialog(null)} name={name} /> + ); + } + + return ( + <> + <TimelinePageTemplate + name={name} + UiComponent={TimelinePageUI} + onManage={(item) => setDialog(item)} + notFoundI18nKey="timeline.timelineNotExist" + /> + {dialogElement} + </> + ); +}; + +export default TimelinePage; diff --git a/FrontEnd/src/app/views/timeline/timeline.sass b/FrontEnd/src/app/views/timeline/timeline.sass new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/FrontEnd/src/app/views/timeline/timeline.sass diff --git a/FrontEnd/src/app/views/user/ChangeAvatarDialog.tsx b/FrontEnd/src/app/views/user/ChangeAvatarDialog.tsx new file mode 100644 index 00000000..ffa2218b --- /dev/null +++ b/FrontEnd/src/app/views/user/ChangeAvatarDialog.tsx @@ -0,0 +1,302 @@ +import React, { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { AxiosError } from "axios"; +import { Modal, Row, Button } from "react-bootstrap"; + +import { UiLogicError } from "@/common"; + +import ImageCropper, { Clip, applyClipToImage } from "../common/ImageCropper"; + +export interface ChangeAvatarDialogProps { + open: boolean; + close: () => void; + process: (blob: Blob) => Promise<void>; +} + +const ChangeAvatarDialog: React.FC<ChangeAvatarDialogProps> = (props) => { + const { t } = useTranslation(); + + const [file, setFile] = React.useState<File | null>(null); + const [fileUrl, setFileUrl] = React.useState<string | null>(null); + const [clip, setClip] = React.useState<Clip | null>(null); + const [ + cropImgElement, + setCropImgElement, + ] = React.useState<HTMLImageElement | null>(null); + const [resultBlob, setResultBlob] = React.useState<Blob | null>(null); + const [resultUrl, setResultUrl] = React.useState<string | null>(null); + + const [state, setState] = React.useState< + | "select" + | "crop" + | "processcrop" + | "preview" + | "uploading" + | "success" + | "error" + >("select"); + + const [message, setMessage] = useState< + string | { type: "custom"; text: string } | null + >("userPage.dialogChangeAvatar.prompt.select"); + + const trueMessage = + message == null + ? null + : typeof message === "string" + ? t(message) + : message.text; + + const closeDialog = props.close; + + const close = React.useCallback((): void => { + if (!(state === "uploading")) { + closeDialog(); + } + }, [state, closeDialog]); + + useEffect(() => { + if (file != null) { + const url = URL.createObjectURL(file); + setClip(null); + setFileUrl(url); + setState("crop"); + return () => { + URL.revokeObjectURL(url); + }; + } else { + setFileUrl(null); + setState("select"); + } + }, [file]); + + React.useEffect(() => { + if (resultBlob != null) { + const url = URL.createObjectURL(resultBlob); + setResultUrl(url); + setState("preview"); + return () => { + URL.revokeObjectURL(url); + }; + } else { + setResultUrl(null); + } + }, [resultBlob]); + + const onSelectFile = React.useCallback( + (e: React.ChangeEvent<HTMLInputElement>): void => { + const files = e.target.files; + if (files == null || files.length === 0) { + setFile(null); + } else { + setFile(files[0]); + } + }, + [] + ); + + const onCropNext = React.useCallback(() => { + if ( + cropImgElement == null || + clip == null || + clip.width === 0 || + file == null + ) { + throw new UiLogicError(); + } + + setState("processcrop"); + void applyClipToImage(cropImgElement, clip, file.type).then((b) => { + setResultBlob(b); + }); + }, [cropImgElement, clip, file]); + + const onCropPrevious = React.useCallback(() => { + setFile(null); + setState("select"); + }, []); + + const onPreviewPrevious = React.useCallback(() => { + setResultBlob(null); + setState("crop"); + }, []); + + const process = props.process; + + const upload = React.useCallback(() => { + if (resultBlob == null) { + throw new UiLogicError(); + } + + setState("uploading"); + process(resultBlob).then( + () => { + setState("success"); + }, + (e: unknown) => { + setState("error"); + setMessage({ type: "custom", text: (e as AxiosError).message }); + } + ); + }, [resultBlob, process]); + + const createPreviewRow = (): React.ReactElement => { + if (resultUrl == null) { + throw new UiLogicError(); + } + return ( + <Row className="justify-content-center"> + <img + className="change-avatar-img" + src={resultUrl} + alt={t("userPage.dialogChangeAvatar.previewImgAlt")} + /> + </Row> + ); + }; + + return ( + <Modal show={props.open} onHide={close}> + <Modal.Header> + <Modal.Title> {t("userPage.dialogChangeAvatar.title")}</Modal.Title> + </Modal.Header> + {(() => { + if (state === "select") { + return ( + <> + <Modal.Body className="container"> + <Row>{t("userPage.dialogChangeAvatar.prompt.select")}</Row> + <Row> + <input type="file" accept="image/*" onChange={onSelectFile} /> + </Row> + </Modal.Body> + <Modal.Footer> + <Button variant="secondary" onClick={close}> + {t("operationDialog.cancel")} + </Button> + </Modal.Footer> + </> + ); + } else if (state === "crop") { + if (fileUrl == null) { + throw new UiLogicError(); + } + return ( + <> + <Modal.Body className="container"> + <Row className="justify-content-center"> + <ImageCropper + clip={clip} + onChange={setClip} + imageUrl={fileUrl} + imageElementCallback={setCropImgElement} + /> + </Row> + <Row>{t("userPage.dialogChangeAvatar.prompt.crop")}</Row> + </Modal.Body> + <Modal.Footer> + <Button variant="secondary" onClick={close}> + {t("operationDialog.cancel")} + </Button> + <Button variant="secondary" onClick={onCropPrevious}> + {t("operationDialog.previousStep")} + </Button> + <Button + color="primary" + onClick={onCropNext} + disabled={ + cropImgElement == null || clip == null || clip.width === 0 + } + > + {t("operationDialog.nextStep")} + </Button> + </Modal.Footer> + </> + ); + } else if (state === "processcrop") { + return ( + <> + <Modal.Body className="container"> + <Row> + {t("userPage.dialogChangeAvatar.prompt.processingCrop")} + </Row> + </Modal.Body> + <Modal.Footer> + <Button variant="secondary" onClick={close}> + {t("operationDialog.cancel")} + </Button> + <Button variant="secondary" onClick={onPreviewPrevious}> + {t("operationDialog.previousStep")} + </Button> + </Modal.Footer> + </> + ); + } else if (state === "preview") { + return ( + <> + <Modal.Body className="container"> + {createPreviewRow()} + <Row>{t("userPage.dialogChangeAvatar.prompt.preview")}</Row> + </Modal.Body> + <Modal.Footer> + <Button variant="secondary" onClick={close}> + {t("operationDialog.cancel")} + </Button> + <Button variant="secondary" onClick={onPreviewPrevious}> + {t("operationDialog.previousStep")} + </Button> + <Button variant="primary" onClick={upload}> + {t("userPage.dialogChangeAvatar.upload")} + </Button> + </Modal.Footer> + </> + ); + } else if (state === "uploading") { + return ( + <> + <Modal.Body className="container"> + {createPreviewRow()} + <Row>{t("userPage.dialogChangeAvatar.prompt.uploading")}</Row> + </Modal.Body> + <Modal.Footer></Modal.Footer> + </> + ); + } else if (state === "success") { + return ( + <> + <Modal.Body className="container"> + <Row className="p-4 text-success"> + {t("operationDialog.success")} + </Row> + </Modal.Body> + <Modal.Footer> + <Button variant="success" onClick={close}> + {t("operationDialog.ok")} + </Button> + </Modal.Footer> + </> + ); + } else { + return ( + <> + <Modal.Body className="container"> + {createPreviewRow()} + <Row className="text-danger">{trueMessage}</Row> + </Modal.Body> + <Modal.Footer> + <Button variant="secondary" onClick={close}> + {t("operationDialog.cancel")} + </Button> + <Button variant="primary" onClick={upload}> + {t("operationDialog.retry")} + </Button> + </Modal.Footer> + </> + ); + } + })()} + </Modal> + ); +}; + +export default ChangeAvatarDialog; diff --git a/FrontEnd/src/app/views/user/ChangeNicknameDialog.tsx b/FrontEnd/src/app/views/user/ChangeNicknameDialog.tsx new file mode 100644 index 00000000..251b18c5 --- /dev/null +++ b/FrontEnd/src/app/views/user/ChangeNicknameDialog.tsx @@ -0,0 +1,28 @@ +import React from "react"; + +import OperationDialog from "../common/OperationDialog"; + +export interface ChangeNicknameDialogProps { + open: boolean; + close: () => void; + onProcess: (newNickname: string) => Promise<void>; +} + +const ChangeNicknameDialog: React.FC<ChangeNicknameDialogProps> = (props) => { + return ( + <OperationDialog + open={props.open} + title="userPage.dialogChangeNickname.title" + titleColor="default" + inputScheme={[ + { type: "text", label: "userPage.dialogChangeNickname.inputLabel" }, + ]} + onProcess={([newNickname]) => { + return props.onProcess(newNickname as string); + }} + close={props.close} + /> + ); +}; + +export default ChangeNicknameDialog; diff --git a/FrontEnd/src/app/views/user/UserInfoCard.tsx b/FrontEnd/src/app/views/user/UserInfoCard.tsx new file mode 100644 index 00000000..888fb18a --- /dev/null +++ b/FrontEnd/src/app/views/user/UserInfoCard.tsx @@ -0,0 +1,80 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Dropdown, Button } from "react-bootstrap"; + +import { timelineVisibilityTooltipTranslationMap } from "@/services/timeline"; +import { useAvatar } from "@/services/user"; + +import BlobImage from "../common/BlobImage"; +import { TimelineCardComponentProps } from "../timeline-common/TimelinePageTemplateUI"; +import InfoCardTemplate from "../timeline-common/InfoCardTemplate"; + +export type PersonalTimelineManageItem = "avatar" | "nickname"; + +export type UserInfoCardProps = TimelineCardComponentProps< + PersonalTimelineManageItem +>; + +const UserInfoCard: React.FC<UserInfoCardProps> = (props) => { + const { + timeline, + collapse, + onMember, + onManage, + syncStatus, + toggleCollapse, + } = props; + const { t } = useTranslation(); + + const avatar = useAvatar(timeline?.owner?.username); + + return ( + <InfoCardTemplate + className={props.className} + syncStatus={syncStatus} + collapse={collapse} + toggleCollapse={toggleCollapse} + > + <div> + <BlobImage blob={avatar} className="avatar" /> + {timeline.owner.nickname} + <small className="ml-3 text-secondary"> + @{timeline.owner.username} + </small> + </div> + <p className="mb-0">{timeline.description}</p> + <small className="mt-1 d-block"> + {t(timelineVisibilityTooltipTranslationMap[timeline.visibility])} + </small> + <div className="text-right mt-2"> + {onManage != null ? ( + <Dropdown> + <Dropdown.Toggle variant="outline-primary"> + {t("timeline.manage")} + </Dropdown.Toggle> + <Dropdown.Menu> + <Dropdown.Item onClick={() => onManage("nickname")}> + {t("timeline.manageItem.nickname")} + </Dropdown.Item> + <Dropdown.Item onClick={() => onManage("avatar")}> + {t("timeline.manageItem.avatar")} + </Dropdown.Item> + <Dropdown.Item onClick={() => onManage("property")}> + {t("timeline.manageItem.property")} + </Dropdown.Item> + <Dropdown.Item onClick={onMember}> + {t("timeline.manageItem.member")} + </Dropdown.Item> + </Dropdown.Menu> + </Dropdown> + ) : ( + <Button variant="outline-primary" onClick={onMember}> + {t("timeline.memberButton")} + </Button> + )} + </div> + </InfoCardTemplate> + ); +}; + +export default UserInfoCard; diff --git a/FrontEnd/src/app/views/user/UserPageUI.tsx b/FrontEnd/src/app/views/user/UserPageUI.tsx new file mode 100644 index 00000000..d405399c --- /dev/null +++ b/FrontEnd/src/app/views/user/UserPageUI.tsx @@ -0,0 +1,18 @@ +import React from "react"; + +import TimelinePageTemplateUI, { + TimelinePageTemplateUIProps, +} from "../timeline-common/TimelinePageTemplateUI"; + +import UserInfoCard, { PersonalTimelineManageItem } from "./UserInfoCard"; + +export type UserPageUIProps = Omit< + TimelinePageTemplateUIProps<PersonalTimelineManageItem>, + "CardComponent" +>; + +const UserPageUI: React.FC<UserPageUIProps> = (props) => { + return <TimelinePageTemplateUI {...props} CardComponent={UserInfoCard} />; +}; + +export default UserPageUI; diff --git a/FrontEnd/src/app/views/user/index.tsx b/FrontEnd/src/app/views/user/index.tsx new file mode 100644 index 00000000..7c0b1563 --- /dev/null +++ b/FrontEnd/src/app/views/user/index.tsx @@ -0,0 +1,72 @@ +import React, { useState } from "react"; +import { useParams } from "react-router"; + +import { UiLogicError } from "@/common"; +import { useUser, userInfoService } from "@/services/user"; + +import TimelinePageTemplate from "../timeline-common/TimelinePageTemplate"; + +import UserPageUI from "./UserPageUI"; +import { PersonalTimelineManageItem } from "./UserInfoCard"; +import ChangeNicknameDialog from "./ChangeNicknameDialog"; +import ChangeAvatarDialog from "./ChangeAvatarDialog"; + +const UserPage: React.FC = (_) => { + const { username } = useParams<{ username: string }>(); + + const user = useUser(); + + const [dialog, setDialog] = useState<null | PersonalTimelineManageItem>(null); + + let dialogElement: React.ReactElement | undefined; + + const closeDialogHandler = (): void => { + setDialog(null); + }; + + if (dialog === "nickname") { + if (user == null) { + throw new UiLogicError("Change nickname without login."); + } + + dialogElement = ( + <ChangeNicknameDialog + open + close={closeDialogHandler} + onProcess={(newNickname) => + userInfoService.setNickname(username, newNickname) + } + /> + ); + } else if (dialog === "avatar") { + if (user == null) { + throw new UiLogicError("Change avatar without login."); + } + + dialogElement = ( + <ChangeAvatarDialog + open + close={closeDialogHandler} + process={(file) => userInfoService.setAvatar(username, file)} + /> + ); + } + + const onManage = React.useCallback((item: PersonalTimelineManageItem) => { + setDialog(item); + }, []); + + return ( + <> + <TimelinePageTemplate + name={`@${username}`} + UiComponent={UserPageUI} + onManage={onManage} + notFoundI18nKey="timeline.userNotExist" + /> + {dialogElement} + </> + ); +}; + +export default UserPage; diff --git a/FrontEnd/src/app/views/user/user.sass b/FrontEnd/src/app/views/user/user.sass new file mode 100644 index 00000000..63a28e05 --- /dev/null +++ b/FrontEnd/src/app/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
|