diff options
Diffstat (limited to 'FrontEnd/src/views')
76 files changed, 1716 insertions, 1138 deletions
diff --git a/FrontEnd/src/views/about/index.tsx b/FrontEnd/src/views/about/index.tsx index 7a72d5ec..11618086 100644 --- a/FrontEnd/src/views/about/index.tsx +++ b/FrontEnd/src/views/about/index.tsx @@ -72,16 +72,16 @@ const AboutPage: React.FC = () => { <div className="d-flex"> <img src={authorAvatarUrl} - className="align-self-start avatar large rounded-circle" + className="align-self-start cru-avatar large cru-round" /> <div> <p> <small>{t("about.author.fullname")}</small> - <span className="text-primary">杨宇千</span> + <span className="cru-color-primary">杨宇千</span> </p> <p> <small>{t("about.author.nickname")}</small> - <span className="text-primary">crupest</span> + <span className="cru-color-primary">crupest</span> </p> <p> <small>{t("about.author.introduction")}</small> @@ -96,7 +96,7 @@ const AboutPage: React.FC = () => { target="_blank" rel="noopener noreferrer" > - <img src={githubLogoUrl} className="about-link-icon text-body" /> + <img src={githubLogoUrl} className="about-link-icon" /> </a> </p> </div> @@ -105,7 +105,7 @@ const AboutPage: React.FC = () => { <h4>{t("about.site.title")}</h4> <p> <Trans i18nKey="about.site.content"> - 0<span className="text-primary">1</span>2<b>3</b>4 + 0<span className="cru-color-primary">1</span>2<b>3</b>4 <a href="#author-info">5</a>6 </Trans> </p> diff --git a/FrontEnd/src/views/admin/Admin.tsx b/FrontEnd/src/views/admin/Admin.tsx index 34e7e2f6..9393a61f 100644 --- a/FrontEnd/src/views/admin/Admin.tsx +++ b/FrontEnd/src/views/admin/Admin.tsx @@ -1,6 +1,5 @@ import React, { Fragment } from "react"; import { Redirect, Route, Switch, useRouteMatch, match } from "react-router"; -import { Container } from "react-bootstrap"; import { useTranslation } from "react-i18next"; import { AuthUser } from "@/services/user"; @@ -29,7 +28,7 @@ const Admin: React.FC<AdminProps> = ({ user }) => { const match = p.match as match<{ name: string }>; const name = match.params["name"]; return ( - <Container> + <div className="container"> <AdminNav /> {(() => { if (name === "users") { @@ -38,7 +37,7 @@ const Admin: React.FC<AdminProps> = ({ user }) => { return <MoreAdmin user={user} />; } })()} - </Container> + </div> ); }} </Route> diff --git a/FrontEnd/src/views/admin/AdminNav.tsx b/FrontEnd/src/views/admin/AdminNav.tsx index 47e2138f..8b4c5fda 100644 --- a/FrontEnd/src/views/admin/AdminNav.tsx +++ b/FrontEnd/src/views/admin/AdminNav.tsx @@ -1,43 +1,29 @@ import React from "react"; -import { Nav } from "react-bootstrap"; -import { useTranslation } from "react-i18next"; -import { useHistory, useRouteMatch } from "react-router"; +import { useRouteMatch } from "react-router"; + +import Tabs from "../common/tab/Tabs"; const AdminNav: React.FC = () => { const match = useRouteMatch<{ name: string }>(); - const history = useHistory(); - - const { t } = useTranslation(); const name = match.params.name; - function toggle(newTab: string): void { - history.push(`/admin/${newTab}`); - } - return ( - <Nav variant="tabs" className="my-2"> - <Nav.Item> - <Nav.Link - active={name === "users"} - onClick={() => { - toggle("users"); - }} - > - {t("admin:nav.users")} - </Nav.Link> - </Nav.Item> - <Nav.Item> - <Nav.Link - active={name === "more"} - onClick={() => { - toggle("more"); - }} - > - {t("admin:nav.more")} - </Nav.Link> - </Nav.Item> - </Nav> + <Tabs + activeTabName={name} + tabs={[ + { + name: "users", + text: "admin:nav.users", + link: "/admin/users", + }, + { + name: "more", + text: "admin:nav.more", + link: "/admin/more", + }, + ]} + /> ); }; diff --git a/FrontEnd/src/views/admin/UserAdmin.tsx b/FrontEnd/src/views/admin/UserAdmin.tsx index eb141520..4ceff8ab 100644 --- a/FrontEnd/src/views/admin/UserAdmin.tsx +++ b/FrontEnd/src/views/admin/UserAdmin.tsx @@ -1,10 +1,9 @@ import React, { useState, useEffect } from "react"; import classnames from "classnames"; -import { ListGroup, Row, Col, Spinner, Button } from "react-bootstrap"; import OperationDialog, { OperationDialogBoolInput, -} from "../common/OperationDialog"; +} from "../common/dailog/OperationDialog"; import { AuthUser } from "@/services/user"; import { @@ -14,7 +13,9 @@ import { UserPermission, } from "@/http/user"; import { Trans, useTranslation } from "react-i18next"; -import TextButton from "../common/button/TextButton"; +import Button from "../common/button/Button"; +import Spinner from "../common/Spinner"; +import FlatButton from "../common/button/FlatButton"; interface DialogProps<TData = undefined, TReturn = undefined> { open: boolean; @@ -45,7 +46,7 @@ const CreateUserDialog: React.FC<DialogProps<undefined, HttpUser>> = ({ password, }) } - close={close} + onClose={close} open={open} onSuccessAndClose={onSuccess} /> @@ -61,7 +62,7 @@ const UserDeleteDialog: React.FC<DialogProps<{ username: string }, unknown>> = return ( <OperationDialog open={open} - close={close} + onClose={close} title="admin:user.dialog.delete.title" themeColor="danger" inputPrompt={() => ( @@ -86,7 +87,7 @@ const UserModifyDialog: React.FC< return ( <OperationDialog open={open} - close={close} + onClose={close} title="admin:user.dialog.modify.title" themeColor="danger" inputPrompt={() => ( @@ -137,7 +138,7 @@ const UserPermissionModifyDialog: React.FC< return ( <OperationDialog open={open} - close={close} + onClose={close} title="admin:user.dialog.modifyPermissions.title" themeColor="danger" inputPrompt={() => ( @@ -203,25 +204,25 @@ const UserItem: React.FC<UserItemProps> = ({ user, on }) => { const [editMaskVisible, setEditMaskVisible] = React.useState<boolean>(false); return ( - <ListGroup.Item className="admin-user-item"> + <div className="admin-user-item"> <i - className="bi-pencil-square float-end icon-button text-warning" + className="bi-pencil-square float-end icon-button cru-color-warning" onClick={() => setEditMaskVisible(true)} /> - <h4 className="text-primary">{user.username}</h4> - <div className="text-secondary"> + <h4 className="cru-color-primary">{user.username}</h4> + <div className="cru-color-secondary"> {t("admin:user.nickname")} {user.nickname} </div> - <div className="text-secondary"> + <div className="cru-color-secondary"> {t("admin:user.uniqueId")} {user.uniqueId} </div> - <div className="text-secondary"> + <div className="cru-color-secondary"> {t("admin:user.permissions")} {user.permissions.map((permission) => { return ( - <span key={permission} className="text-danger"> + <span key={permission} className="cru-color-danger"> {permission}{" "} </span> ); @@ -231,18 +232,18 @@ const UserItem: React.FC<UserItemProps> = ({ user, on }) => { className={classnames("edit-mask", !editMaskVisible && "d-none")} onClick={() => setEditMaskVisible(false)} > - <TextButton text="admin:user.modify" onClick={on[kModify]} /> - <TextButton + <FlatButton text="admin:user.modify" onClick={on[kModify]} /> + <FlatButton text="admin:user.modifyPermissions" onClick={on[kModifyPermission]} /> - <TextButton + <FlatButton text="admin:user.delete" color="danger" onClick={on[kDelete]} /> </div> - </ListGroup.Item> + </div> ); }; @@ -251,8 +252,6 @@ interface UserAdminProps { } const UserAdmin: React.FC<UserAdminProps> = () => { - const { t } = useTranslation(); - type DialogInfo = | null | { @@ -372,26 +371,25 @@ const UserAdmin: React.FC<UserAdminProps> = () => { return ( <> - <Row className="justify-content-end my-2"> - <Col xs="auto"> + <div className="row justify-content-end my-2"> + <div className="col col-auto"> <Button - variant="outline-success" + text="admin:create" + color="success" onClick={() => setDialog({ type: "create", }) } - > - {t("admin:create")} - </Button> - </Col> - </Row> + /> + </div> + </div> {userComponents} {dialogNode} </> ); } else { - return <Spinner animation="border" />; + return <Spinner />; } }; diff --git a/FrontEnd/src/views/center/CenterBoards.tsx b/FrontEnd/src/views/center/CenterBoards.tsx index f5200415..392c2d08 100644 --- a/FrontEnd/src/views/center/CenterBoards.tsx +++ b/FrontEnd/src/views/center/CenterBoards.tsx @@ -1,5 +1,4 @@ import React from "react"; -import { Row, Col } from "react-bootstrap"; import { useTranslation } from "react-i18next"; import { pushAlert } from "@/services/alert"; @@ -18,10 +17,10 @@ const CenterBoards: React.FC = () => { return ( <> - <Row className="justify-content-center"> - <Col xs="12" md="6"> - <Row> - <Col xs="12" className="my-2"> + <div className="row justify-content-center"> + <div className="col col-12 col-md-6"> + <div className="row"> + <div className="col col-12 my-2"> <TimelineBoard title={t("home.bookmarkTimeline")} load={() => getHttpBookmarkClient().list()} @@ -52,8 +51,8 @@ const CenterBoards: React.FC = () => { }, }} /> - </Col> - <Col xs="12" className="my-2"> + </div> + <div className="col col-12 my-2"> <TimelineBoard title={t("home.highlightTimeline")} load={() => getHttpHighlightClient().list()} @@ -88,18 +87,18 @@ const CenterBoards: React.FC = () => { : undefined } /> - </Col> - </Row> - </Col> - <Col xs="12" md="6" className="my-2"> + </div> + </div> + </div> + <div className="col-12 col-md-6 my-2"> <TimelineBoard title={t("home.relatedTimeline")} load={() => getHttpTimelineClient().listTimeline({ relate: user.username }) } /> - </Col> - </Row> + </div> + </div> </> ); }; diff --git a/FrontEnd/src/views/center/TimelineBoard.tsx b/FrontEnd/src/views/center/TimelineBoard.tsx index d6f6228d..8c1f5fac 100644 --- a/FrontEnd/src/views/center/TimelineBoard.tsx +++ b/FrontEnd/src/views/center/TimelineBoard.tsx @@ -1,7 +1,6 @@ import React from "react"; import classnames from "classnames"; import { Link } from "react-router-dom"; -import { Spinner } from "react-bootstrap"; import { HttpTimelineInfo } from "@/http/timeline"; @@ -10,6 +9,7 @@ import UserTimelineLogo from "../common/UserTimelineLogo"; import LoadFailReload from "../common/LoadFailReload"; import FlatButton from "../common/button/FlatButton"; import Card from "../common/Card"; +import Spinner from "../common/Spinner"; interface TimelineBoardItemProps { timeline: HttpTimelineInfo; @@ -48,16 +48,16 @@ const TimelineBoardItem: React.FC<TimelineBoardItemProps> = ({ <TimelineLogo className="icon" /> )} <span className="title">{title}</span> - <small className="ms-2 text-secondary">{name}</small> + <small className="ms-2 cru-color-secondary">{name}</small> <span className="flex-grow-1"></span> {actions != null ? ( <div className="right"> <i - className="bi-trash icon-button text-danger px-2" + className="bi-trash icon-button cru-color-danger px-2" onClick={actions.onDelete} /> <i - className="bi-grip-vertical icon-button text-gray px-2 touch-action-none" + className="bi-grip-vertical icon-button px-2 touch-action-none" onPointerDown={(e) => { e.currentTarget.setPointerCapture(e.pointerId); actions.onMove.start(e); @@ -208,7 +208,8 @@ const TimelineBoardItemContainer: React.FC<TimelineBoardItemContainerProps> = ({ interface TimelineBoardUIProps { title?: string; - timelines: HttpTimelineInfo[] | "offline" | "loading"; + state: "offline" | "loading" | "loaded"; + timelines: HttpTimelineInfo[]; onReload: () => void; className?: string; editHandler?: { @@ -218,7 +219,7 @@ interface TimelineBoardUIProps { } const TimelineBoardUI: React.FC<TimelineBoardUIProps> = (props) => { - const { title, timelines, className, editHandler } = props; + const { title, state, timelines, className, editHandler } = props; const editable = editHandler != null; @@ -246,13 +247,13 @@ const TimelineBoardUI: React.FC<TimelineBoardUIProps> = (props) => { ))} </div> {(() => { - if (timelines === "loading") { + if (state === "loading") { return ( <div className="d-flex flex-grow-1 justify-content-center align-items-center"> - <Spinner variant="primary" animation="border" /> + <Spinner /> </div> ); - } else if (timelines === "offline") { + } else if (state === "offline") { return ( <div className="d-flex flex-grow-1 justify-content-center align-items-center"> <LoadFailReload onReload={props.onReload} /> @@ -301,36 +302,39 @@ const TimelineBoard: React.FC<TimelineBoardProps> = ({ load, editHandler, }) => { - const [timelines, setTimelines] = React.useState< - HttpTimelineInfo[] | "offline" | "loading" - >("loading"); + const [state, setState] = React.useState<"offline" | "loading" | "loaded">( + "loading" + ); + const [timelines, setTimelines] = React.useState<HttpTimelineInfo[]>([]); React.useEffect(() => { let subscribe = true; - if (timelines === "loading") { + if (state === "loading") { void load().then( (timelines) => { if (subscribe) { + setState("loaded"); setTimelines(timelines); } }, () => { - setTimelines("offline"); + setState("offline"); } ); } return () => { subscribe = false; }; - }, [load, timelines]); + }, [load, state]); return ( <TimelineBoardUI title={title} className={className} + state={state} timelines={timelines} onReload={() => { - setTimelines("loading"); + setState("loaded"); }} editHandler={ typeof timelines === "object" && editHandler != null diff --git a/FrontEnd/src/views/center/TimelineCreateDialog.tsx b/FrontEnd/src/views/center/TimelineCreateDialog.tsx index b4e25ba1..4871a5e0 100644 --- a/FrontEnd/src/views/center/TimelineCreateDialog.tsx +++ b/FrontEnd/src/views/center/TimelineCreateDialog.tsx @@ -2,7 +2,7 @@ import React from "react"; import { useHistory } from "react-router"; import { validateTimelineName } from "@/services/timeline"; -import OperationDialog from "../common/OperationDialog"; +import OperationDialog from "../common/dailog/OperationDialog"; import { getHttpTimelineClient, HttpTimelineInfo } from "@/http/timeline"; interface TimelineCreateDialogProps { @@ -16,7 +16,7 @@ const TimelineCreateDialog: React.FC<TimelineCreateDialogProps> = (props) => { return ( <OperationDialog open={props.open} - close={props.close} + onClose={props.close} themeColor="success" title="home.createDialog.title" inputScheme={ diff --git a/FrontEnd/src/views/center/index.tsx b/FrontEnd/src/views/center/index.tsx index 28d8b372..77bb6ec8 100644 --- a/FrontEnd/src/views/center/index.tsx +++ b/FrontEnd/src/views/center/index.tsx @@ -1,11 +1,10 @@ import React from "react"; import { useHistory } from "react-router"; -import { useTranslation } from "react-i18next"; -import { Row, Container, Button, Col } from "react-bootstrap"; import { useUserLoggedIn } from "@/services/user"; import SearchInput from "../common/SearchInput"; +import Button from "../common/button/Button"; import CenterBoards from "./CenterBoards"; import TimelineCreateDialog from "./TimelineCreateDialog"; @@ -14,8 +13,6 @@ import "./index.css"; const HomePage: React.FC = () => { const history = useHistory(); - const { t } = useTranslation(); - const user = useUserLoggedIn(); const [navText, setNavText] = React.useState<string>(""); @@ -24,9 +21,9 @@ const HomePage: React.FC = () => { return ( <> - <Container> - <Row className="my-3 justify-content-center"> - <Col xs={12} sm={8} lg={6}> + <div className="container"> + <div className="row my-3 justify-content-center"> + <div className="col col-12 col-sm-8 col-lg-6"> <SearchInput className="justify-content-center" value={navText} @@ -37,20 +34,19 @@ const HomePage: React.FC = () => { additionalButton={ user != null && ( <Button - variant="outline-success" + text="home.createButton" + color="success" onClick={() => { setDialog("create"); }} - > - {t("home.createButton")} - </Button> + /> ) } /> - </Col> - </Row> + </div> + </div> <CenterBoards /> - </Container> + </div> {dialog === "create" && ( <TimelineCreateDialog open diff --git a/FrontEnd/src/views/common/AppBar.css b/FrontEnd/src/views/common/AppBar.css new file mode 100644 index 00000000..3ec4fa36 --- /dev/null +++ b/FrontEnd/src/views/common/AppBar.css @@ -0,0 +1,95 @@ +.app-bar {
+ display: flex;
+ align-items: center;
+ height: 56px;
+ position: fixed;
+ z-index: 1030;
+ top: 0;
+ left: 0;
+ right: 0;
+ background-color: var(--cru-primary-color);
+ transition: background-color 1s;
+}
+
+.app-bar .cru-avatar {
+ background-color: white;
+}
+
+.app-bar a {
+ color: var(--cru-primary-t1-color);
+ text-decoration: none;
+ margin: 0 1em;
+ transition: color 1s;
+}
+.app-bar a:hover {
+ color: var(--cru-primary-t-color);
+}
+.app-bar a.active {
+ color: var(--cru-primary-t-color);
+}
+
+.app-bar-brand {
+ display: flex;
+ align-items: center;
+}
+
+.app-bar-brand-icon {
+ height: 2em;
+}
+
+.app-bar-main-area {
+ display: flex;
+ flex-grow: 1;
+}
+
+.app-bar-link-area {
+ display: flex;
+ align-items: center;
+ flex-shrink: 0;
+}
+
+.app-bar-user-area {
+ display: flex;
+ align-items: center;
+ flex-shrink: 0;
+ margin-left: auto;
+}
+
+.small-screen .app-bar-main-area {
+ position: absolute;
+ top: 56px;
+ left: 0;
+ right: 0;
+ transform-origin: top;
+ transition: transform 0.6s, background-color 1s;
+ background-color: var(--cru-primary-color);
+ flex-direction: column;
+}
+.small-screen .app-bar-main-area.app-bar-collapse {
+ transform: scale(1, 0);
+}
+.small-screen .app-bar-main-area a {
+ text-align: left;
+ padding: 0.5em 0.5em;
+}
+.small-screen .app-bar-link-area {
+ flex-direction: column;
+ align-items: stretch;
+}
+.small-screen .app-bar-user-area {
+ flex-direction: column;
+ align-items: stretch;
+ margin-left: unset;
+}
+.small-screen .app-bar-avatar {
+ align-self: flex-end;
+}
+
+.app-bar-toggler {
+ margin-left: auto;
+ font-size: 2em;
+ margin-right: 1em;
+ color: var(--cru-primary-t-color);
+ cursor: pointer;
+ user-select: none;
+}
diff --git a/FrontEnd/src/views/common/AppBar.tsx b/FrontEnd/src/views/common/AppBar.tsx index ebc8bf0c..5d62a88d 100644 --- a/FrontEnd/src/views/common/AppBar.tsx +++ b/FrontEnd/src/views/common/AppBar.tsx @@ -9,7 +9,7 @@ import { useUser } from "@/services/user"; import TimelineLogo from "./TimelineLogo"; import UserAvatar from "./user/UserAvatar"; -import "./index.css"; +import "./AppBar.css"; const AppBar: React.FC = (_) => { const { t } = useTranslation(); @@ -68,7 +68,7 @@ const AppBar: React.FC = (_) => { "/", <UserAvatar username={user.username} - className="avatar small rounded-circle bg-white cursor-pointer ml-auto" + className="cru-avatar small cru-round cursor-pointer ml-auto" />, "app-bar-avatar" ) diff --git a/FrontEnd/src/views/common/Card.css b/FrontEnd/src/views/common/Card.css index fb90bd59..6de0dd8e 100644 --- a/FrontEnd/src/views/common/Card.css +++ b/FrontEnd/src/views/common/Card.css @@ -11,5 +11,5 @@ }
.cru-card:hover {
- border-color: var(--tl-primary-color);
+ border-color: var(--cru-primary-color);
}
diff --git a/FrontEnd/src/views/common/ConfirmDialog.tsx b/FrontEnd/src/views/common/ConfirmDialog.tsx deleted file mode 100644 index 72940c51..00000000 --- a/FrontEnd/src/views/common/ConfirmDialog.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { convertI18nText, I18nText } from "@/common"; -import React from "react"; -import { Modal, Button } from "react-bootstrap"; -import { useTranslation } from "react-i18next"; - -const ConfirmDialog: React.FC<{ - onClose: () => void; - onConfirm: () => void; - title: I18nText; - body: I18nText; -}> = ({ onClose, onConfirm, title, body }) => { - const { t } = useTranslation(); - - return ( - <Modal onHide={onClose} show centered> - <Modal.Header> - <Modal.Title className="text-danger"> - {convertI18nText(title, t)} - </Modal.Title> - </Modal.Header> - <Modal.Body>{convertI18nText(body, t)}</Modal.Body> - <Modal.Footer> - <Button variant="secondary" onClick={onClose}> - {t("operationDialog.cancel")} - </Button> - <Button - variant="danger" - onClick={() => { - onConfirm(); - onClose(); - }} - > - {t("operationDialog.confirm")} - </Button> - </Modal.Footer> - </Modal> - ); -}; - -export default ConfirmDialog; diff --git a/FrontEnd/src/views/common/ImageCropper.css b/FrontEnd/src/views/common/ImageCropper.css new file mode 100644 index 00000000..2c4d0a8c --- /dev/null +++ b/FrontEnd/src/views/common/ImageCropper.css @@ -0,0 +1,38 @@ +.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, 0.8);
+ 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/views/common/ImageCropper.tsx b/FrontEnd/src/views/common/ImageCropper.tsx index 2ef5b7ed..be44200a 100644 --- a/FrontEnd/src/views/common/ImageCropper.tsx +++ b/FrontEnd/src/views/common/ImageCropper.tsx @@ -3,6 +3,8 @@ import classnames from "classnames"; import { UiLogicError } from "@/common"; +import "./ImageCropper.css"; + export interface Clip { left: number; top: number; diff --git a/FrontEnd/src/views/common/LoadingButton.tsx b/FrontEnd/src/views/common/LoadingButton.tsx deleted file mode 100644 index cd9f1adc..00000000 --- a/FrontEnd/src/views/common/LoadingButton.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import React from "react"; -import { Button, ButtonProps, Spinner } from "react-bootstrap"; - -const LoadingButton: React.FC<{ loading?: boolean } & ButtonProps> = ({ - loading, - variant, - disabled, - ...otherProps -}) => { - return ( - <Button - variant={variant != null ? `outline-${variant}` : "outline-primary"} - disabled={disabled || loading} - {...otherProps} - > - {otherProps.children} - {loading ? ( - <Spinner - className="ms-1" - variant={variant} - animation="grow" - size="sm" - /> - ) : null} - </Button> - ); -}; - -export default LoadingButton; diff --git a/FrontEnd/src/views/common/LoadingPage.tsx b/FrontEnd/src/views/common/LoadingPage.tsx index 590fafa0..8c1e681a 100644 --- a/FrontEnd/src/views/common/LoadingPage.tsx +++ b/FrontEnd/src/views/common/LoadingPage.tsx @@ -1,10 +1,11 @@ import React from "react"; -import { Spinner } from "react-bootstrap"; + +import Spinner from "./Spinner"; 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" /> + <Spinner /> </div> ); }; diff --git a/FrontEnd/src/views/common/SearchInput.css b/FrontEnd/src/views/common/SearchInput.css new file mode 100644 index 00000000..2943b3a2 --- /dev/null +++ b/FrontEnd/src/views/common/SearchInput.css @@ -0,0 +1,4 @@ +.cru-search-input {
+ display: flex;
+ flex-wrap: wrap;
+}
diff --git a/FrontEnd/src/views/common/SearchInput.tsx b/FrontEnd/src/views/common/SearchInput.tsx index ccb6dad6..da3f1c19 100644 --- a/FrontEnd/src/views/common/SearchInput.tsx +++ b/FrontEnd/src/views/common/SearchInput.tsx @@ -1,7 +1,10 @@ import React, { useCallback } from "react"; import classnames from "classnames"; import { useTranslation } from "react-i18next"; -import { Spinner, Form, Button } from "react-bootstrap"; + +import LoadingButton from "./button/LoadingButton"; + +import "./SearchInput.css"; export interface SearchInputProps { value: string; @@ -38,14 +41,15 @@ const SearchInput: React.FC<SearchInputProps> = (props) => { ); return ( - <Form + <div className={classnames( "cru-search-input", alwaysOneline ? "flex-nowrap" : "flex-sm-nowrap", props.className )} > - <Form.Control + <input + type="text" className="me-sm-2 flex-grow-1" value={props.value} onChange={onInputChange} @@ -63,15 +67,11 @@ const SearchInput: React.FC<SearchInputProps> = (props) => { "flex-shrink-0" )} > - {props.loading ? ( - <Spinner variant="primary" animation="border" /> - ) : ( - <Button variant="outline-primary" onClick={props.onButtonClick}> - {props.buttonText ?? t("search")} - </Button> - )} + <LoadingButton loading={props.loading} onClick={props.onButtonClick}> + {props.buttonText ?? t("search")} + </LoadingButton> </div> - </Form> + </div> ); }; diff --git a/FrontEnd/src/views/common/Skeleton.css b/FrontEnd/src/views/common/Skeleton.css new file mode 100644 index 00000000..db1a1c34 --- /dev/null +++ b/FrontEnd/src/views/common/Skeleton.css @@ -0,0 +1,14 @@ +.cru-skeleton {
+ padding: 0 1em;
+}
+
+.cru-skeleton-line {
+ height: 1em;
+ background-color: #e6e6e6;
+ margin: 0.7em 0;
+ border-radius: 0.2em;
+}
+
+.cru-skeleton-line.last {
+ width: 50%;
+}
diff --git a/FrontEnd/src/views/common/Skeleton.tsx b/FrontEnd/src/views/common/Skeleton.tsx index 14886c71..58d34215 100644 --- a/FrontEnd/src/views/common/Skeleton.tsx +++ b/FrontEnd/src/views/common/Skeleton.tsx @@ -1,6 +1,8 @@ import React from "react"; import classnames from "classnames"; -import { range } from "lodash"; +import range from "lodash/range"; + +import "./Skeleton.css"; export interface SkeletonProps { lineNumber?: number; diff --git a/FrontEnd/src/views/common/Spinner.css b/FrontEnd/src/views/common/Spinner.css new file mode 100644 index 00000000..a1de68d2 --- /dev/null +++ b/FrontEnd/src/views/common/Spinner.css @@ -0,0 +1,13 @@ +@keyframes cru-spinner-animation {
+ from {
+ transform: scale(0,0);
+ }
+}
+
+.cru-spinner {
+ display: inline-block;
+ animation: cru-spinner-animation 0.5s infinite alternate;
+ background-color: currentColor;
+ border-radius: 50%;
+ transform-origin: center;
+}
diff --git a/FrontEnd/src/views/common/Spinner.tsx b/FrontEnd/src/views/common/Spinner.tsx new file mode 100644 index 00000000..4c735fef --- /dev/null +++ b/FrontEnd/src/views/common/Spinner.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import classnames from "classnames"; + +import { PaletteColorType } from "@/palette"; + +import "./Spinner.css"; + +export interface SpinnerProps { + size?: "sm" | "md" | "lg" | number | string; + color?: PaletteColorType; + className?: string; + style?: React.CSSProperties; +} + +export default function Spinner( + props: SpinnerProps +): React.ReactElement | null { + const { size, color, className, style } = props; + const calculatedSize = + size === "sm" + ? "18px" + : size === "md" + ? "30px" + : size === "lg" + ? "42px" + : typeof size === "number" + ? size + : size == null + ? "20px" + : size; + const calculatedColor = color ?? "primary"; + + return ( + <span + className={classnames( + "cru-spinner", + `cru-color-${calculatedColor}`, + className + )} + style={{ width: calculatedSize, height: calculatedSize, ...style }} + /> + ); +} diff --git a/FrontEnd/src/views/common/ToggleIconButton.tsx b/FrontEnd/src/views/common/ToggleIconButton.tsx deleted file mode 100644 index c4d2d132..00000000 --- a/FrontEnd/src/views/common/ToggleIconButton.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import React from "react"; -import classnames from "classnames"; - -export interface ToggleIconButtonProps - extends React.HTMLAttributes<HTMLElement> { - state: boolean; - trueIconClassName: string; - falseIconClassName: string; -} - -const ToggleIconButton: React.FC<ToggleIconButtonProps> = ({ - state, - className, - trueIconClassName, - falseIconClassName, - ...otherProps -}) => { - return ( - <i - className={classnames( - state ? trueIconClassName : falseIconClassName, - "icon-button", - className - )} - {...otherProps} - /> - ); -}; - -export default ToggleIconButton; diff --git a/FrontEnd/src/views/common/alert/AlertHost.tsx b/FrontEnd/src/views/common/alert/AlertHost.tsx index 949be7ed..ba6d6a0f 100644 --- a/FrontEnd/src/views/common/alert/AlertHost.tsx +++ b/FrontEnd/src/views/common/alert/AlertHost.tsx @@ -1,16 +1,13 @@ import React from "react"; import without from "lodash/without"; import { useTranslation } from "react-i18next"; -import { Alert } from "react-bootstrap"; +import classNames from "classnames"; -import { - alertService, - AlertInfoEx, - kAlertHostId, - AlertInfo, -} from "@/services/alert"; +import { alertService, AlertInfoEx, AlertInfo } from "@/services/alert"; import { convertI18nText } from "@/common"; +import "./alert.css"; + interface AutoCloseAlertProps { alert: AlertInfo; close: () => void; @@ -52,29 +49,36 @@ export const AutoCloseAlert: React.FC<AutoCloseAlertProps> = (props) => { }; return ( - <Alert - className="m-3" - variant={alert.type ?? "primary"} + <div + className={classNames( + "m-3 cru-alert", + "cru-" + (alert.type ?? "primary") + )} onClick={cancelTimer} - onClose={close} - dismissible > - {(() => { - const { message } = alert; - if (typeof message === "function") { - const Message = message; - return <Message />; - } else return convertI18nText(message, t); - })()} - </Alert> + <div className="cru-alert-content"> + {(() => { + const { message, customMessage } = alert; + if (customMessage != null) { + return customMessage; + } else { + return convertI18nText(message, t); + } + })()} + </div> + <div className="cru-alert-close-button-container"> + <i + className={classNames("icon-button bi-x cru-alert-close-button")} + onClick={close} + /> + </div> + </div> ); }; const AlertHost: React.FC = () => { const [alerts, setAlerts] = React.useState<AlertInfoEx[]>([]); - // react guarantee that state setters are stable, so we don't need to add it to dependency list - React.useEffect(() => { const consume = (alert: AlertInfoEx): void => { setAlerts((old) => [...old, alert]); @@ -87,7 +91,7 @@ const AlertHost: React.FC = () => { }, []); return ( - <div id={kAlertHostId} className="alert-container"> + <div className="alert-container"> {alerts.map((alert) => { return ( <AutoCloseAlert diff --git a/FrontEnd/src/views/common/alert/alert.css b/FrontEnd/src/views/common/alert/alert.css new file mode 100644 index 00000000..328f5f9b --- /dev/null +++ b/FrontEnd/src/views/common/alert/alert.css @@ -0,0 +1,32 @@ +.alert-container {
+ position: fixed;
+ z-index: 1040;
+}
+
+.cru-alert {
+ border-radius: 5px;
+ border: var(--cru-theme-color) 1px solid;
+ color: var(--cru-theme-t-color);
+ background-color: var(--cru-theme-r1-color);
+
+ display: flex;
+ overflow: hidden;
+}
+
+.cru-alert-content {
+ padding: 0.5em 2em;
+}
+
+.cru-alert-close-button-container {
+ margin-left: auto;
+ width: 2em;
+ text-align: center;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background-color: var(--cru-theme-t-color);
+}
+
+.cru-alert-close-button {
+ color: var(--cru-theme-color);
+}
diff --git a/FrontEnd/src/views/common/alert/alert.sass b/FrontEnd/src/views/common/alert/alert.sass deleted file mode 100644 index c3560b87..00000000 --- a/FrontEnd/src/views/common/alert/alert.sass +++ /dev/null @@ -1,15 +0,0 @@ -.alert-container
- position: fixed
- z-index: $zindex-popover
-
-@include media-breakpoint-up(sm)
- .alert-container
- bottom: 0
- right: 0
-
-@include media-breakpoint-down(sm)
- .alert-container
- bottom: 0
- right: 0
- left: 0
- text-align: center
diff --git a/FrontEnd/src/views/common/button/Button.css b/FrontEnd/src/views/common/button/Button.css new file mode 100644 index 00000000..c34176f6 --- /dev/null +++ b/FrontEnd/src/views/common/button/Button.css @@ -0,0 +1,51 @@ +.cru-button:not(.outline) {
+ color: var(--cru-theme-t-color);
+ cursor: pointer;
+ padding: 0.2em 0.5em;
+ border-radius: 0.2em;
+ border: none;
+ transition: all 0.5s;
+ background-color: var(--cru-theme-color);
+}
+
+.cru-button:not(.outline):hover {
+ background-color: var(--cru-theme-f1-color);
+}
+
+.cru-button:not(.outline):active {
+ background-color: var(--cru-theme-f2-color);
+}
+
+.cru-button:not(.outline):disabled {
+ background-color: var(--cru-disable-color);
+ cursor: auto;
+}
+
+.cru-button.outline {
+ color: var(--cru-theme-color);
+ border: var(--cru-theme-color) 1px solid;
+ cursor: pointer;
+ padding: 0.2em 0.5em;
+ border-radius: 0.2em;
+ transition: all 0.6s;
+ background-color: white;
+}
+
+.cru-button.outline:hover {
+ color: var(--cru-theme-f1-color);
+ border-color: var(--cru-theme-f1-color);
+ background-color: var(--cru-background-color);
+}
+
+.cru-button.outline:active {
+ color: var(--cru-theme-f2-color);
+ border-color: var(--cru-theme-f2-color);
+ background-color: var(--cru-background-1-color);
+}
+
+.cru-button.outline:disabled {
+ color: var(--cru-disable-color);
+ border-color: var(--cru-disable-color);
+ background-color: white;
+ cursor: auto;
+}
diff --git a/FrontEnd/src/views/common/button/Button.tsx b/FrontEnd/src/views/common/button/Button.tsx new file mode 100644 index 00000000..a39ef8a7 --- /dev/null +++ b/FrontEnd/src/views/common/button/Button.tsx @@ -0,0 +1,33 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; + +import { calculateProps, CommonButtonProps } from "./common"; + +import "./Button.css"; + +function _Button( + props: CommonButtonProps & { + outline?: boolean; + customButtonClassName?: string; + }, + ref: React.ForwardedRef<HTMLButtonElement> +): React.ReactElement | null { + const { t } = useTranslation(); + + const { customButtonClassName, outline, ...otherProps } = props; + + const { newProps, children } = calculateProps( + otherProps, + customButtonClassName ?? "cru-button" + (outline ? " outline" : ""), + t + ); + + return ( + <button ref={ref} {...newProps}> + {children} + </button> + ); +} + +const Button = React.forwardRef(_Button); +export default Button; diff --git a/FrontEnd/src/views/common/button/FlatButton.css b/FrontEnd/src/views/common/button/FlatButton.css index 522563b9..f0d33153 100644 --- a/FrontEnd/src/views/common/button/FlatButton.css +++ b/FrontEnd/src/views/common/button/FlatButton.css @@ -5,44 +5,14 @@ border: none;
background-color: transparent;
transition: all 0.6s;
-}
-
-.cru-flat-button:hover:not(.disabled) {
- background-color: #e9ecef;
+ color: var(--cru-theme-color);
}
.cru-flat-button.disabled {
+ color: var(--cru-theme-l1-color);
cursor: default;
}
-.cru-flat-button.primary {
- color: var(--tl-primary-color);
-}
-
-.cru-flat-button.primary.disabled {
- color: var(--tl-primary-lighter-color);
-}
-
-.cru-flat-button.secondary {
- color: var(--tl-secondary-color);
-}
-
-.cru-flat-button.secondary.disabled {
- color: var(--tl-secondary-lighter-color);
-}
-
-.cru-flat-button.success {
- color: var(--tl-success-color);
-}
-
-.cru-flat-button.success.disabled {
- color: var(--tl-success-lighter-color);
-}
-
-.cru-flat-button.danger {
- color: var(--tl-danger-color);
-}
-
-.cru-flat-button.danger.disabled {
- color: var(--tl-danger-ligher-color);
+.cru-flat-button:hover:not(.disabled) {
+ background-color: #e9ecef;
}
diff --git a/FrontEnd/src/views/common/button/FlatButton.tsx b/FrontEnd/src/views/common/button/FlatButton.tsx index 6351971a..266ea908 100644 --- a/FrontEnd/src/views/common/button/FlatButton.tsx +++ b/FrontEnd/src/views/common/button/FlatButton.tsx @@ -1,39 +1,16 @@ import React from "react"; -import { useTranslation } from "react-i18next"; -import classNames from "classnames"; -import { convertI18nText, I18nText } from "@/common"; -import { PaletteColorType } from "@/palette"; +import { CommonButtonProps } from "./common"; +import Button from "./Button"; import "./FlatButton.css"; function _FlatButton( - { - text, - color, - onClick, - className, - style, - }: { - text: I18nText; - color?: PaletteColorType; - onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void; - className?: string; - style?: React.CSSProperties; - }, + props: CommonButtonProps, ref: React.ForwardedRef<HTMLButtonElement> ): React.ReactElement | null { - const { t } = useTranslation(); - return ( - <button - ref={ref} - className={classNames("cru-flat-button", color ?? "primary", className)} - onClick={onClick} - style={style} - > - {convertI18nText(text, t)} - </button> + <Button ref={ref} customButtonClassName="cru-flat-button" {...props} /> ); } diff --git a/FrontEnd/src/views/common/button/LoadingButton.tsx b/FrontEnd/src/views/common/button/LoadingButton.tsx new file mode 100644 index 00000000..a7e34f91 --- /dev/null +++ b/FrontEnd/src/views/common/button/LoadingButton.tsx @@ -0,0 +1,28 @@ +import React from "react"; + +import { CommonButtonProps } from "./common"; +import Button from "./Button"; +import Spinner from "../Spinner"; + +const LoadingButton: React.FC<{ loading?: boolean } & CommonButtonProps> = ({ + loading, + disabled, + color, + ...otherProps +}) => { + return ( + <Button + color={color} + outline + disabled={disabled || loading} + {...otherProps} + > + {otherProps.children} + {loading ? ( + <Spinner className="cru-align-text-bottom ms-1" color={color} /> + ) : null} + </Button> + ); +}; + +export default LoadingButton; diff --git a/FrontEnd/src/views/common/button/TextButton.css b/FrontEnd/src/views/common/button/TextButton.css deleted file mode 100644 index dc5abaaa..00000000 --- a/FrontEnd/src/views/common/button/TextButton.css +++ /dev/null @@ -1,36 +0,0 @@ -.cru-text-button {
- background: transparent;
- border: none;
-}
-
-.cru-text-button.primary {
- color: var(--tl-primary-color);
-}
-
-.cru-text-button.primary:hover {
- color: var(--tl-primary-lighter-color);
-}
-
-.cru-text-button.secondary {
- color: var(--tl-secondary-color);
-}
-
-.cru-text-button.secondary:hover {
- color: var(--tl-secondary-lighter-color);
-}
-
-.cru-text-button.success {
- color: var(--tl-success-color);
-}
-
-.cru-text-button.success:hover {
- color: var(--tl-success-lighter-color);
-}
-
-.cru-text-button.danger {
- color: var(--tl-danger-color);
-}
-
-.cru-text-button.danger:hover {
- color: var(--tl-danger-lighter-color);
-}
diff --git a/FrontEnd/src/views/common/button/TextButton.tsx b/FrontEnd/src/views/common/button/TextButton.tsx deleted file mode 100644 index 1a2bac94..00000000 --- a/FrontEnd/src/views/common/button/TextButton.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import React from "react"; -import { useTranslation } from "react-i18next"; -import classNames from "classnames"; - -import { convertI18nText, I18nText } from "@/common"; -import { PaletteColorType } from "@/palette"; - -import "./TextButton.css"; - -function _TextButton( - { - text, - color, - onClick, - className, - style, - }: { - text: I18nText; - color?: PaletteColorType; - onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void; - className?: string; - style?: React.CSSProperties; - }, - ref: React.ForwardedRef<HTMLButtonElement> -): React.ReactElement | null { - const { t } = useTranslation(); - - return ( - <button - ref={ref} - className={classNames("cru-text-button", color ?? "primary", className)} - onClick={onClick} - style={style} - > - {convertI18nText(text, t)} - </button> - ); -} - -const TextButton = React.forwardRef(_TextButton); -export default TextButton; diff --git a/FrontEnd/src/views/common/button/common.ts b/FrontEnd/src/views/common/button/common.ts new file mode 100644 index 00000000..0d84bae0 --- /dev/null +++ b/FrontEnd/src/views/common/button/common.ts @@ -0,0 +1,35 @@ +import React from "react"; +import classNames from "classnames"; +import { TFunction } from "i18next"; + +import { convertI18nText, I18nText } from "@/common"; +import { PaletteColorType } from "@/palette"; + +export type CommonButtonProps = { + text?: I18nText; + color?: PaletteColorType; +} & React.ButtonHTMLAttributes<HTMLButtonElement>; + +export function calculateProps( + props: CommonButtonProps, + buttonClassName: string, + t: TFunction +): { + children: React.ReactNode; + newProps: React.ButtonHTMLAttributes<HTMLButtonElement>; +} { + const { text, color, className, children, ...otherProps } = props; + const newProps = { + className: classNames( + buttonClassName, + color != null ? "cru-" + color : "cru-primary", + className + ), + ...otherProps, + }; + + return { + children: text != null ? convertI18nText(text, t) : children, + newProps: newProps, + }; +} diff --git a/FrontEnd/src/views/common/dailog/ConfirmDialog.tsx b/FrontEnd/src/views/common/dailog/ConfirmDialog.tsx new file mode 100644 index 00000000..c10b1cdb --- /dev/null +++ b/FrontEnd/src/views/common/dailog/ConfirmDialog.tsx @@ -0,0 +1,43 @@ +import { convertI18nText, I18nText } from "@/common"; +import React from "react"; +import { useTranslation } from "react-i18next"; + +import Button from "../button/Button"; +import Dialog from "./Dialog"; + +const ConfirmDialog: React.FC<{ + open?: boolean; + onClose: () => void; + onConfirm: () => void; + title: I18nText; + body: I18nText; +}> = ({ open, onClose, onConfirm, title, body }) => { + const { t } = useTranslation(); + + return ( + <Dialog onClose={onClose} open={open}> + <h3 className="cru-color-danger">{convertI18nText(title, t)}</h3> + <hr /> + <p>{convertI18nText(body, t)}</p> + <hr /> + <div className="cru-dialog-bottom-area"> + <Button + text="operationDialog.cancel" + color="secondary" + outline + onClick={onClose} + /> + <Button + text="operationDialog.confirm" + color="danger" + onClick={() => { + onConfirm(); + onClose(); + }} + /> + </div> + </Dialog> + ); +}; + +export default ConfirmDialog; diff --git a/FrontEnd/src/views/common/dailog/Dialog.css b/FrontEnd/src/views/common/dailog/Dialog.css new file mode 100644 index 00000000..22b420fc --- /dev/null +++ b/FrontEnd/src/views/common/dailog/Dialog.css @@ -0,0 +1,35 @@ +.cru-dialog-overlay {
+ position: fixed;
+ z-index: 1040;
+ left: 0;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ background-color: rgba(255, 255, 255, 0.92);
+
+ display: flex;
+ padding: 2em 0;
+
+ overflow: auto;
+}
+
+.cru-dialog-container {
+ max-width: 100%;
+ min-width: 30vw;
+
+ margin: auto;
+
+ border: var(--cru-primary-color) 1px solid;
+ border-radius: 5px;
+ padding: 1.5em;
+ background-color: white;
+}
+
+.cru-dialog-bottom-area {
+ display: flex;
+ justify-content: flex-end;
+}
+
+.cru-dialog-bottom-area > * {
+ margin: 0 0.5em;
+}
diff --git a/FrontEnd/src/views/common/dailog/Dialog.tsx b/FrontEnd/src/views/common/dailog/Dialog.tsx new file mode 100644 index 00000000..ee58080f --- /dev/null +++ b/FrontEnd/src/views/common/dailog/Dialog.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import ReactDOM from "react-dom"; + +import "./Dialog.css"; + +export interface DialogProps { + onClose: () => void; + open?: boolean; + children?: React.ReactNode; + disableCloseOnClickOnOverlay?: boolean; +} + +export default function Dialog(props: DialogProps): React.ReactElement | null { + const { open, onClose, children, disableCloseOnClickOnOverlay } = props; + + return open + ? ReactDOM.createPortal( + <div + className="cru-dialog-overlay" + onClick={ + disableCloseOnClickOnOverlay + ? undefined + : () => { + onClose(); + } + } + > + <div + className="cru-dialog-container" + onClick={(e) => e.stopPropagation()} + > + {children} + </div> + </div>, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + document.getElementById("portal")! + ) + : null; +} diff --git a/FrontEnd/src/views/common/dailog/FullPageDialog.css b/FrontEnd/src/views/common/dailog/FullPageDialog.css new file mode 100644 index 00000000..a196981c --- /dev/null +++ b/FrontEnd/src/views/common/dailog/FullPageDialog.css @@ -0,0 +1,30 @@ +.cru-full-page {
+ position: fixed;
+ z-index: 1030;
+ left: 0;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ background-color: white;
+ padding-top: 56px;
+}
+
+.cru-full-page-top-bar {
+ height: 56px;
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ z-index: 1;
+ background-color: var(--cru-primary-color);
+ display: flex;
+ align-items: center;
+}
+
+.cru-full-page-content-container {
+ overflow: scroll;
+}
+
+.cru-full-page-back-button {
+ color: var(--cru-primary-t-color);
+}
diff --git a/FrontEnd/src/views/common/FullPage.tsx b/FrontEnd/src/views/common/dailog/FullPageDialog.tsx index 1b59045a..2e77dbb0 100644 --- a/FrontEnd/src/views/common/FullPage.tsx +++ b/FrontEnd/src/views/common/dailog/FullPageDialog.tsx @@ -1,26 +1,29 @@ import React from "react"; +import { createPortal } from "react-dom"; import classnames from "classnames"; -export interface FullPageProps { +import "./FullPageDialog.css"; + +export interface FullPageDialogProps { show: boolean; onBack: () => void; contentContainerClassName?: string; } -const FullPage: React.FC<FullPageProps> = ({ +const FullPageDialog: React.FC<FullPageDialogProps> = ({ show, onBack, children, contentContainerClassName, }) => { - return ( + return createPortal( <div className="cru-full-page" style={{ display: show ? undefined : "none" }} > <div className="cru-full-page-top-bar"> <i - className="icon-button bi-arrow-left text-white ms-3" + className="icon-button bi-arrow-left ms-3 cru-full-page-back-button" onClick={onBack} /> </div> @@ -32,8 +35,10 @@ const FullPage: React.FC<FullPageProps> = ({ > {children} </div> - </div> + </div>, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + document.getElementById("portal")! ); }; -export default FullPage; +export default FullPageDialog; diff --git a/FrontEnd/src/views/common/dailog/OperationDialog.css b/FrontEnd/src/views/common/dailog/OperationDialog.css new file mode 100644 index 00000000..26c3920b --- /dev/null +++ b/FrontEnd/src/views/common/dailog/OperationDialog.css @@ -0,0 +1,26 @@ +.cru-operation-dialog-group {
+ display: block;
+ margin: 0.4em 0;
+}
+
+.cru-operation-dialog-label {
+ display: block;
+ font-size: 0.8em;
+ color: var(--cru-primary-color);
+}
+
+.cru-operation-dialog-inline-label {
+ margin-inline-start: 0.5em;
+}
+
+.cru-operation-dialog-error-text {
+ display: block;
+ font-size: 0.8em;
+ color: var(--cru-danger-color);
+}
+
+.cru-operation-dialog-helper-text {
+ display: block;
+ font-size: 0.8em;
+ color: var(--cru-primary-color);
+}
diff --git a/FrontEnd/src/views/common/OperationDialog.tsx b/FrontEnd/src/views/common/dailog/OperationDialog.tsx index ac4c51b9..6bc846dd 100644 --- a/FrontEnd/src/views/common/OperationDialog.tsx +++ b/FrontEnd/src/views/common/dailog/OperationDialog.tsx @@ -1,12 +1,18 @@ import React, { useState } from "react"; import { useTranslation } from "react-i18next"; -import { Form, Button, Modal } from "react-bootstrap"; import { TwitterPicker } from "react-color"; import moment from "moment"; import { convertI18nText, I18nText, UiLogicError } from "@/common"; -import LoadingButton from "./LoadingButton"; +import { PaletteColorType } from "@/palette"; + +import Button from "../button/Button"; +import LoadingButton from "../button/LoadingButton"; +import Dialog from "./Dialog"; + +import "./OperationDialog.css"; +import classNames from "classnames"; interface DefaultErrorPromptProps { error?: string; @@ -15,13 +21,13 @@ interface DefaultErrorPromptProps { const DefaultErrorPrompt: React.FC<DefaultErrorPromptProps> = (props) => { const { t } = useTranslation(); - let result = <p className="text-danger">{t("operationDialog.error")}</p>; + let result = <p className="cru-color-danger">{t("operationDialog.error")}</p>; if (props.error != null) { result = ( <> {result} - <p className="text-danger">{props.error}</p> + <p className="cru-color-danger">{props.error}</p> </> ); } @@ -45,6 +51,7 @@ export interface OperationDialogBoolInput { type: "bool"; label: I18nText; initValue?: boolean; + helperText?: string; } export interface OperationDialogSelectInputOption { @@ -71,6 +78,7 @@ export interface OperationDialogDateTimeInput { type: "datetime"; label?: I18nText; initValue?: string; + helperText?: string; } export type OperationDialogInput = @@ -141,9 +149,9 @@ export interface OperationDialogProps< OperationInputInfoList extends readonly OperationDialogInput[] > { open: boolean; - close: () => void; + onClose: () => void; title: I18nText | (() => React.ReactNode); - themeColor?: "danger" | "success" | string; + themeColor?: PaletteColorType; onProcess: ( inputs: MapOperationInputInfoValueTypeList<OperationInputInfoList> ) => Promise<TData>; @@ -204,7 +212,7 @@ const OperationDialog = < const close = (): void => { if (step.type !== "process") { - props.close(); + props.onClose(); if (step.type === "success" && props.onSuccessAndClose) { props.onSuccessAndClose(step.data); } @@ -278,7 +286,7 @@ const OperationDialog = < body = ( <> - <Modal.Body> + <div> {inputPrompt} {inputScheme.map((item, index) => { const value = values[index]; @@ -289,50 +297,84 @@ const OperationDialog = < if (item.type === "text") { return ( - <Form.Group key={index}> + <div + key={index} + className={classNames( + "cru-operation-dialog-group", + error != null ? "error" : null + )} + > {item.label && ( - <Form.Label>{convertI18nText(item.label, t)}</Form.Label> + <label className="cru-operation-dialog-label"> + {convertI18nText(item.label, t)} + </label> )} - <Form.Control + <input type={item.password === true ? "password" : "text"} value={value as string} onChange={(e) => { const v = e.target.value; updateValue(index, v); }} - isInvalid={error != null} disabled={process} /> {error != null && ( - <Form.Control.Feedback type="invalid"> + <div className="cru-operation-dialog-error-text"> {error} - </Form.Control.Feedback> + </div> )} {item.helperText && ( - <Form.Text>{t(item.helperText)}</Form.Text> + <div className="cru-operation-dialog-helper-text"> + {t(item.helperText)} + </div> )} - </Form.Group> + </div> ); } else if (item.type === "bool") { return ( - <Form.Group key={index}> - <Form.Check<"input"> + <div + key={index} + className={classNames( + "cru-operation-dialog-group", + error != null ? "error" : null + )} + > + <input type="checkbox" checked={value as boolean} onChange={(event) => { updateValue(index, event.currentTarget.checked); }} - label={convertI18nText(item.label, t)} disabled={process} /> - </Form.Group> + <label className="cru-operation-dialog-inline-label"> + {convertI18nText(item.label, t)} + </label> + {error != null && ( + <div className="cru-operation-dialog-error-text"> + {error} + </div> + )} + {item.helperText && ( + <div className="cru-operation-dialog-helper-text"> + {t(item.helperText)} + </div> + )} + </div> ); } else if (item.type === "select") { return ( - <Form.Group key={index}> - <Form.Label>{convertI18nText(item.label, t)}</Form.Label> - <Form.Control - as="select" + <div + key={index} + className={classNames( + "cru-operation-dialog-group", + error != null ? "error" : null + )} + > + <label className="cru-operation-dialog-label"> + {convertI18nText(item.label, t)} + </label> + <select value={value as string} onChange={(event) => { updateValue(index, event.target.value); @@ -347,14 +389,20 @@ const OperationDialog = < </option> ); })} - </Form.Control> - </Form.Group> + </select> + </div> ); } else if (item.type === "color") { return ( - <Form.Group key={index}> + <div + key={index} + className={classNames( + "cru-operation-dialog-group", + error != null ? "error" : null + )} + > {item.canBeNull ? ( - <Form.Check<"input"> + <input type="checkbox" checked={value !== null} onChange={(event) => { @@ -364,52 +412,61 @@ const OperationDialog = < updateValue(index, null); } }} - label={convertI18nText(item.label, t)} disabled={process} /> - ) : ( - <Form.Label>{convertI18nText(item.label, t)}</Form.Label> - )} + ) : null} + <label className="cru-operation-dialog-inline-label"> + {convertI18nText(item.label, t)} + </label> {value !== null && ( <TwitterPicker color={value as string} + triangle="hide" onChange={(result) => updateValue(index, result.hex)} /> )} - </Form.Group> + </div> ); } else if (item.type === "datetime") { return ( - <Form.Group key={index}> + <div + key={index} + className={classNames( + "cru-operation-dialog-group", + error != null ? "error" : null + )} + > {item.label && ( - <Form.Label>{convertI18nText(item.label, t)}</Form.Label> + <label className="cru-operation-dialog-label"> + {convertI18nText(item.label, t)} + </label> )} - <Form.Control + <input type="datetime-local" value={value as string} onChange={(e) => { const v = e.target.value; updateValue(index, v); }} - isInvalid={error != null} disabled={process} /> - {error != null && ( - <Form.Control.Feedback type="invalid"> - {error} - </Form.Control.Feedback> - )} - </Form.Group> + {error != null && <div>{error}</div>} + </div> ); } })} - </Modal.Body> - <Modal.Footer> - <Button variant="outline-secondary" onClick={close}> - {t("operationDialog.cancel")} - </Button> + </div> + <hr /> + <div className="cru-dialog-bottom-area"> + <Button + text="operationDialog.cancel" + color="secondary" + outline + onClick={close} + disabled={process} + /> <LoadingButton - variant={props.themeColor} + color={props.themeColor} loading={process} disabled={!canProcess} onClick={() => { @@ -421,7 +478,7 @@ const OperationDialog = < > {t("operationDialog.confirm")} </LoadingButton> - </Modal.Footer> + </div> </> ); } else { @@ -431,7 +488,7 @@ const OperationDialog = < content = props.successPrompt?.(result.data) ?? t("operationDialog.success"); if (typeof content === "string") - content = <p className="text-success">{content}</p>; + content = <p className="cru-color-success">{content}</p>; } else { content = props.failurePrompt?.(result.data) ?? <DefaultErrorPrompt />; if (typeof content === "string") @@ -439,12 +496,11 @@ const OperationDialog = < } body = ( <> - <Modal.Body>{content}</Modal.Body> - <Modal.Footer> - <Button variant="primary" onClick={close}> - {t("operationDialog.ok")} - </Button> - </Modal.Footer> + <div>{content}</div> + <hr /> + <div className="cru-dialog-bottom-area"> + <Button text="operationDialog.ok" color="primary" onClick={close} /> + </div> </> ); } @@ -455,16 +511,19 @@ const OperationDialog = < : convertI18nText(props.title, t); return ( - <Modal show={props.open} onHide={close}> - <Modal.Header + <Dialog open={props.open} onClose={close}> + <h3 className={ - props.themeColor != null ? "text-" + props.themeColor : undefined + props.themeColor != null + ? "cru-color-" + props.themeColor + : "cru-color-primary" } > {title} - </Modal.Header> + </h3> + <hr /> {body} - </Modal> + </Dialog> ); }; diff --git a/FrontEnd/src/views/common/index.css b/FrontEnd/src/views/common/index.css index bfd82b58..a4ce8cf3 100644 --- a/FrontEnd/src/views/common/index.css +++ b/FrontEnd/src/views/common/index.css @@ -1,245 +1,272 @@ -.image-cropper-container {
- position: relative;
- box-sizing: border-box;
- user-select: none;
-}
+:root {
+ --cru-background-color: #f8f9fa;
+ --cru-background-1-color: #e9ecef;
+ --cru-background-2-color: #dee2e6;
-.image-cropper-container img {
- position: absolute;
- left: 0;
- top: 0;
- width: 100%;
- height: 100%;
-}
+ --cru-disable-color: #ced4da;
-.image-cropper-mask-container {
- position: absolute;
- left: 0;
- top: 0;
- right: 0;
- bottom: 0;
- overflow: hidden;
+ --cru-primary-color: rgb(0, 123, 255);
+ --cru-primary-l1-color: rgb(26, 136, 255);
+ --cru-primary-l2-color: rgb(51, 149, 255);
+ --cru-primary-l3-color: rgb(77, 163, 255);
+ --cru-primary-d1-color: rgb(0, 111, 230);
+ --cru-primary-d2-color: rgb(0, 98, 204);
+ --cru-primary-d3-color: rgb(0, 86, 179);
+ --cru-primary-f1-color: rgb(0, 111, 230);
+ --cru-primary-f2-color: rgb(0, 98, 204);
+ --cru-primary-f3-color: rgb(0, 86, 179);
+ --cru-primary-r1-color: rgb(26, 136, 255);
+ --cru-primary-r2-color: rgb(51, 149, 255);
+ --cru-primary-r3-color: rgb(77, 163, 255);
+ --cru-primary-t-color: rgb(255, 255, 255);
+ --cru-primary-t1-color: rgb(230, 230, 230);
+ --cru-primary-t2-color: rgb(204, 204, 204);
+ --cru-primary-t3-color: rgb(179, 179, 179);
+ --cru-primary-enhance-color: rgb(77, 163, 255);
+ --cru-primary-enhance-l1-color: rgb(94, 172, 255);
+ --cru-primary-enhance-l2-color: rgb(112, 181, 255);
+ --cru-primary-enhance-l3-color: rgb(130, 190, 255);
+ --cru-primary-enhance-d1-color: rgb(43, 145, 255);
+ --cru-primary-enhance-d2-color: rgb(10, 128, 255);
+ --cru-primary-enhance-d3-color: rgb(0, 112, 232);
+ --cru-primary-enhance-f1-color: rgb(94, 172, 255);
+ --cru-primary-enhance-f2-color: rgb(112, 181, 255);
+ --cru-primary-enhance-f3-color: rgb(130, 190, 255);
+ --cru-primary-enhance-r1-color: rgb(43, 145, 255);
+ --cru-primary-enhance-r2-color: rgb(10, 128, 255);
+ --cru-primary-enhance-r3-color: rgb(0, 112, 232);
+ --cru-primary-enhance-t-color: rgb(0, 0, 0);
+ --cru-primary-enhance-t1-color: rgb(26, 26, 26);
+ --cru-primary-enhance-t2-color: rgb(51, 51, 51);
+ --cru-primary-enhance-t3-color: rgb(77, 77, 77);
+ --cru-secondary-color: rgb(128, 128, 128);
+ --cru-secondary-l1-color: rgb(141, 141, 141);
+ --cru-secondary-l2-color: rgb(153, 153, 153);
+ --cru-secondary-l3-color: rgb(166, 166, 166);
+ --cru-secondary-d1-color: rgb(115, 115, 115);
+ --cru-secondary-d2-color: rgb(102, 102, 102);
+ --cru-secondary-d3-color: rgb(90, 90, 90);
+ --cru-secondary-f1-color: rgb(115, 115, 115);
+ --cru-secondary-f2-color: rgb(102, 102, 102);
+ --cru-secondary-f3-color: rgb(90, 90, 90);
+ --cru-secondary-r1-color: rgb(141, 141, 141);
+ --cru-secondary-r2-color: rgb(153, 153, 153);
+ --cru-secondary-r3-color: rgb(166, 166, 166);
+ --cru-secondary-t-color: rgb(255, 255, 255);
+ --cru-secondary-t1-color: rgb(230, 230, 230);
+ --cru-secondary-t2-color: rgb(204, 204, 204);
+ --cru-secondary-t3-color: rgb(179, 179, 179);
+ --cru-danger-color: rgb(255, 0, 0);
+ --cru-danger-l1-color: rgb(255, 26, 26);
+ --cru-danger-l2-color: rgb(255, 51, 51);
+ --cru-danger-l3-color: rgb(255, 77, 77);
+ --cru-danger-d1-color: rgb(230, 0, 0);
+ --cru-danger-d2-color: rgb(204, 0, 0);
+ --cru-danger-d3-color: rgb(179, 0, 0);
+ --cru-danger-f1-color: rgb(230, 0, 0);
+ --cru-danger-f2-color: rgb(204, 0, 0);
+ --cru-danger-f3-color: rgb(179, 0, 0);
+ --cru-danger-r1-color: rgb(255, 26, 26);
+ --cru-danger-r2-color: rgb(255, 51, 51);
+ --cru-danger-r3-color: rgb(255, 77, 77);
+ --cru-danger-t-color: rgb(255, 255, 255);
+ --cru-danger-t1-color: rgb(230, 230, 230);
+ --cru-danger-t2-color: rgb(204, 204, 204);
+ --cru-danger-t3-color: rgb(179, 179, 179);
+ --cru-success-color: rgb(0, 128, 0);
+ --cru-success-l1-color: rgb(0, 166, 0);
+ --cru-success-l2-color: rgb(0, 204, 0);
+ --cru-success-l3-color: rgb(0, 243, 0);
+ --cru-success-d1-color: rgb(0, 115, 0);
+ --cru-success-d2-color: rgb(0, 102, 0);
+ --cru-success-d3-color: rgb(0, 90, 0);
+ --cru-success-f1-color: rgb(0, 115, 0);
+ --cru-success-f2-color: rgb(0, 102, 0);
+ --cru-success-f3-color: rgb(0, 90, 0);
+ --cru-success-r1-color: rgb(0, 166, 0);
+ --cru-success-r2-color: rgb(0, 204, 0);
+ --cru-success-r3-color: rgb(0, 243, 0);
+ --cru-success-t-color: rgb(255, 255, 255);
+ --cru-success-t1-color: rgb(230, 230, 230);
+ --cru-success-t2-color: rgb(204, 204, 204);
+ --cru-success-t3-color: rgb(179, 179, 179);
}
-.image-cropper-mask {
- position: absolute;
- box-shadow: 0 0 0 10000px rgba(255, 255, 255, 0.8);
- touch-action: none;
+.cru-primary {
+ --cru-theme-color: var(--cru-primary-color);
+ --cru-theme-l1-color: var(--cru-primary-l1-color);
+ --cru-theme-l2-color: var(--cru-primary-l2-color);
+ --cru-theme-l3-color: var(--cru-primary-l3-color);
+ --cru-theme-d1-color: var(--cru-primary-d1-color);
+ --cru-theme-d2-color: var(--cru-primary-d2-color);
+ --cru-theme-d3-color: var(--cru-primary-d3-color);
+ --cru-theme-f1-color: var(--cru-primary-f1-color);
+ --cru-theme-f2-color: var(--cru-primary-f2-color);
+ --cru-theme-f3-color: var(--cru-primary-f3-color);
+ --cru-theme-r1-color: var(--cru-primary-r1-color);
+ --cru-theme-r2-color: var(--cru-primary-r2-color);
+ --cru-theme-r3-color: var(--cru-primary-r3-color);
+ --cru-theme-t-color: var(--cru-primary-t-color);
+ --cru-theme-t1-color: var(--cru-primary-t1-color);
+ --cru-theme-t2-color: var(--cru-primary-t2-color);
+ --cru-theme-t3-color: var(--cru-primary-t3-color);
}
-.image-cropper-handler {
- position: absolute;
- width: 26px;
- height: 26px;
- border: black solid 2px;
- border-radius: 50%;
- background: white;
- touch-action: none;
+.cru-primary-enhance {
+ --cru-theme-color: var(--cru-primary-enhance-color);
+ --cru-theme-l1-color: var(--cru-primary-enhance-l1-color);
+ --cru-theme-l2-color: var(--cru-primary-enhance-l2-color);
+ --cru-theme-l3-color: var(--cru-primary-enhance-l3-color);
+ --cru-theme-d1-color: var(--cru-primary-enhance-d1-color);
+ --cru-theme-d2-color: var(--cru-primary-enhance-d2-color);
+ --cru-theme-d3-color: var(--cru-primary-enhance-d3-color);
+ --cru-theme-f1-color: var(--cru-primary-enhance-f1-color);
+ --cru-theme-f2-color: var(--cru-primary-enhance-f2-color);
+ --cru-theme-f3-color: var(--cru-primary-enhance-f3-color);
+ --cru-theme-r1-color: var(--cru-primary-enhance-r1-color);
+ --cru-theme-r2-color: var(--cru-primary-enhance-r2-color);
+ --cru-theme-r3-color: var(--cru-primary-enhance-r3-color);
+ --cru-theme-t-color: var(--cru-primary-enhance-t-color);
+ --cru-theme-t1-color: var(--cru-primary-enhance-t1-color);
+ --cru-theme-t2-color: var(--cru-primary-enhance-t2-color);
+ --cru-theme-t3-color: var(--cru-primary-enhance-t3-color);
}
-.app-bar {
- display: flex;
- align-items: center;
- height: 56px;
- position: fixed;
- z-index: 1030;
- top: 0;
- left: 0;
- right: 0;
- background-color: var(--tl-primary-color);
- transition: background-color 1s;
-}
-.app-bar a {
- color: var(--tl-text-on-primary-inactive-color);
- text-decoration: none;
- margin: 0 1em;
-}
-.app-bar a:hover {
- color: var(--tl-text-on-primary-color);
-}
-.app-bar a.active {
- color: var(--tl-text-on-primary-color);
+.cru-secondary {
+ --cru-theme-color: var(--cru-secondary-color);
+ --cru-theme-l1-color: var(--cru-secondary-l1-color);
+ --cru-theme-l2-color: var(--cru-secondary-l2-color);
+ --cru-theme-l3-color: var(--cru-secondary-l3-color);
+ --cru-theme-d1-color: var(--cru-secondary-d1-color);
+ --cru-theme-d2-color: var(--cru-secondary-d2-color);
+ --cru-theme-d3-color: var(--cru-secondary-d3-color);
+ --cru-theme-f1-color: var(--cru-secondary-f1-color);
+ --cru-theme-f2-color: var(--cru-secondary-f2-color);
+ --cru-theme-f3-color: var(--cru-secondary-f3-color);
+ --cru-theme-r1-color: var(--cru-secondary-r1-color);
+ --cru-theme-r2-color: var(--cru-secondary-r2-color);
+ --cru-theme-r3-color: var(--cru-secondary-r3-color);
+ --cru-theme-t-color: var(--cru-secondary-t-color);
+ --cru-theme-t1-color: var(--cru-secondary-t1-color);
+ --cru-theme-t2-color: var(--cru-secondary-t2-color);
+ --cru-theme-t3-color: var(--cru-secondary-t3-color);
}
-.app-bar-brand {
- display: flex;
- align-items: center;
+.cru-success {
+ --cru-theme-color: var(--cru-success-color);
+ --cru-theme-l1-color: var(--cru-success-l1-color);
+ --cru-theme-l2-color: var(--cru-success-l2-color);
+ --cru-theme-l3-color: var(--cru-success-l3-color);
+ --cru-theme-d1-color: var(--cru-success-d1-color);
+ --cru-theme-d2-color: var(--cru-success-d2-color);
+ --cru-theme-d3-color: var(--cru-success-d3-color);
+ --cru-theme-f1-color: var(--cru-success-f1-color);
+ --cru-theme-f2-color: var(--cru-success-f2-color);
+ --cru-theme-f3-color: var(--cru-success-f3-color);
+ --cru-theme-r1-color: var(--cru-success-r1-color);
+ --cru-theme-r2-color: var(--cru-success-r2-color);
+ --cru-theme-r3-color: var(--cru-success-r3-color);
+ --cru-theme-t-color: var(--cru-success-t-color);
+ --cru-theme-t1-color: var(--cru-success-t1-color);
+ --cru-theme-t2-color: var(--cru-success-t2-color);
+ --cru-theme-t3-color: var(--cru-success-t3-color);
}
-.app-bar-brand-icon {
- height: 2em;
+.cru-danger {
+ --cru-theme-color: var(--cru-danger-color);
+ --cru-theme-l1-color: var(--cru-danger-l1-color);
+ --cru-theme-l2-color: var(--cru-danger-l2-color);
+ --cru-theme-l3-color: var(--cru-danger-l3-color);
+ --cru-theme-d1-color: var(--cru-danger-d1-color);
+ --cru-theme-d2-color: var(--cru-danger-d2-color);
+ --cru-theme-d3-color: var(--cru-danger-d3-color);
+ --cru-theme-f1-color: var(--cru-danger-f1-color);
+ --cru-theme-f2-color: var(--cru-danger-f2-color);
+ --cru-theme-f3-color: var(--cru-danger-f3-color);
+ --cru-theme-r1-color: var(--cru-danger-r1-color);
+ --cru-theme-r2-color: var(--cru-danger-r2-color);
+ --cru-theme-r3-color: var(--cru-danger-r3-color);
+ --cru-theme-t-color: var(--cru-danger-t-color);
+ --cru-theme-t1-color: var(--cru-danger-t1-color);
+ --cru-theme-t2-color: var(--cru-danger-t2-color);
+ --cru-theme-t3-color: var(--cru-danger-t3-color);
}
-.app-bar-main-area {
- display: flex;
- flex-grow: 1;
+.cru-color-primary {
+ color: var(--cru-primary-color);
}
-.app-bar-link-area {
- display: flex;
- align-items: center;
- flex-shrink: 0;
+.cru-color-secondary {
+ color: var(--cru-secondary-color);
}
-.app-bar-user-area {
- display: flex;
- align-items: center;
- flex-shrink: 0;
- margin-left: auto;
+.cru-color-success {
+ color: var(--cru-success-color);
}
-.small-screen .app-bar-main-area {
- position: absolute;
- top: 56px;
- left: 0;
- right: 0;
- transform-origin: top;
- transition: transform 0.6s, background-color 1s;
- background-color: var(--tl-primary-color);
- flex-direction: column;
-}
-.small-screen .app-bar-main-area.app-bar-collapse {
- transform: scale(1, 0);
-}
-.small-screen .app-bar-main-area a {
- text-align: left;
- padding: 0.5em 0.5em;
-}
-.small-screen .app-bar-link-area {
- flex-direction: column;
- align-items: stretch;
-}
-.small-screen .app-bar-user-area {
- flex-direction: column;
- align-items: stretch;
- margin-left: unset;
-}
-.small-screen .app-bar-avatar {
- align-self: flex-end;
+.cru-color-danger {
+ color: var(--cru-danger-color);
}
-.app-bar-toggler {
- margin-left: auto;
- font-size: 2em;
- margin-right: 1em;
- color: var(--tl-text-on-primary-color);
- cursor: pointer;
- user-select: none;
+.cru-text-center {
+ text-align: center;
}
-.cru-skeleton {
- padding: 0 1em;
+.cru-text-end {
+ text-align: end;
}
-.cru-skeleton-line {
- height: 1em;
- background-color: #e6e6e6;
- margin: 0.7em 0;
- border-radius: 0.2em;
-}
-.cru-skeleton-line.last {
- width: 50%;
+.cru-float-right {
+ float: right;
}
-.cru-full-page {
- position: fixed;
- z-index: 1031;
- left: 0;
- top: 0;
- right: 0;
- bottom: 0;
- background-color: white;
- padding-top: 56px;
+.cru-align-text-bottom {
+ vertical-align: text-bottom;
}
-.cru-full-page-top-bar {
- height: 56px;
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- z-index: 1;
- background-color: var(--tl-primary-color);
- display: flex;
- align-items: center;
+.cru-align-middle {
+ vertical-align: middle;
}
-.cru-full-page-content-container {
- overflow: scroll;
+.cru-clearfix::after {
+ clear: both;
}
-.cru-menu {
- min-width: 200px;
+.cru-fill-parent {
+ width: 100%;
+ height: 100%;
}
-.cru-menu-item {
- font-size: 1.2em;
- padding: 0.5em 1.5em;
+.icon-button {
+ font-size: 1.4rem;
cursor: pointer;
}
-.cru-menu-item.color-primary {
- color: #0d6efd;
-}
-.cru-menu-item.color-primary:hover {
- color: white;
- background-color: #0d6efd;
-}
-.cru-menu-item.color-secondary {
- color: #6c757d;
-}
-.cru-menu-item.color-secondary:hover {
- color: white;
- background-color: #6c757d;
-}
-.cru-menu-item.color-success {
- color: #198754;
-}
-.cru-menu-item.color-success:hover {
- color: white;
- background-color: #198754;
-}
-.cru-menu-item.color-info {
- color: #0dcaf0;
-}
-.cru-menu-item.color-info:hover {
- color: white;
- background-color: #0dcaf0;
-}
-.cru-menu-item.color-warning {
- color: #ffc107;
-}
-.cru-menu-item.color-warning:hover {
- color: white;
- background-color: #ffc107;
-}
-.cru-menu-item.color-danger {
- color: #dc3545;
-}
-.cru-menu-item.color-danger:hover {
- color: white;
- background-color: #dc3545;
-}
-.cru-menu-item.color-light {
- color: #f8f9fa;
+
+.icon-button.large {
+ font-size: 1.6rem;
}
-.cru-menu-item.color-light:hover {
- color: white;
- background-color: #f8f9fa;
+
+.icon-button.primary-enhance {
+ color: var(--cru-primary-enhance-color);
}
-.cru-menu-item.color-dark {
- color: #212529;
+
+.cru-avatar {
+ width: 60px;
+ height: 60px;
}
-.cru-menu-item.color-dark:hover {
- color: white;
- background-color: #212529;
+
+.cru-avatar.large {
+ width: 100px;
+ height: 100px;
}
-.cru-menu-item-icon {
- margin-right: 1em;
+.cru-avatar.small {
+ width: 40px;
+ height: 40px;
}
-.cru-menu-divider {
- border-top: 1px solid #e9ecef;
+.cru-round {
+ border-radius: 50%;
}
.cru-tab-pages-action-area {
@@ -247,11 +274,6 @@ align-items: center;
}
-.cru-search-input {
- display: flex;
- flex-wrap: wrap;
-}
-
.alert-container {
position: fixed;
z-index: 1070;
diff --git a/FrontEnd/src/views/common/menu/Menu.css b/FrontEnd/src/views/common/menu/Menu.css new file mode 100644 index 00000000..c3fa82c4 --- /dev/null +++ b/FrontEnd/src/views/common/menu/Menu.css @@ -0,0 +1,24 @@ +.cru-menu {
+ min-width: 200px;
+}
+
+.cru-menu-item {
+ font-size: 1em;
+ padding: 0.5em 1.5em;
+ cursor: pointer;
+ transition: all 0.5s;
+ color: var(--cru-theme-color);
+}
+
+.cru-menu-item:hover {
+ color: var(--cru-theme-t-color);
+ background-color: var(--cru-theme-color);
+}
+
+.cru-menu-item-icon {
+ margin-right: 1em;
+}
+
+.cru-menu-divider {
+ border-top: 1px solid #e9ecef;
+}
diff --git a/FrontEnd/src/views/common/Menu.tsx b/FrontEnd/src/views/common/menu/Menu.tsx index ae73a331..d2f65391 100644 --- a/FrontEnd/src/views/common/Menu.tsx +++ b/FrontEnd/src/views/common/menu/Menu.tsx @@ -1,9 +1,11 @@ import React from "react"; import classnames from "classnames"; -import { OverlayTrigger, OverlayTriggerProps, Popover } from "react-bootstrap"; import { useTranslation } from "react-i18next"; -import { BootstrapThemeColor, convertI18nText, I18nText } from "@/common"; +import { convertI18nText, I18nText } from "@/common"; +import { PaletteColorType } from "@/palette"; + +import "./Menu.css"; export type MenuItem = | { @@ -13,23 +15,29 @@ export type MenuItem = type: "button"; text: I18nText; iconClassName?: string; - color?: BootstrapThemeColor; + color?: PaletteColorType; onClick: () => void; }; export type MenuItems = MenuItem[]; -export interface MenuProps { +export type MenuProps = { items: MenuItems; - className?: string; onItemClicked?: () => void; -} + className?: string; + style?: React.CSSProperties; +}; -const Menu: React.FC<MenuProps> = ({ items, className, onItemClicked }) => { +export default function _Menu({ + items, + onItemClicked, + className, + style, +}: MenuProps): React.ReactElement | null { const { t } = useTranslation(); return ( - <div className={classnames("cru-menu", className)}> + <div className={classnames("cru-menu", className)} style={style}> {items.map((item, index) => { if (item.type === "divider") { return <div key={index} className="cru-menu-divider" />; @@ -39,7 +47,7 @@ const Menu: React.FC<MenuProps> = ({ items, className, onItemClicked }) => { key={index} className={classnames( "cru-menu-item", - `color-${item.color ?? "primary"}` + `cru-${item.color ?? "primary"}` )} onClick={() => { item.onClick(); @@ -61,32 +69,4 @@ const Menu: React.FC<MenuProps> = ({ items, className, onItemClicked }) => { })} </div> ); -}; - -export default Menu; - -export interface PopupMenuProps { - items: MenuItems; - children: OverlayTriggerProps["children"]; } - -export const PopupMenu: React.FC<PopupMenuProps> = ({ items, children }) => { - const [show, setShow] = React.useState<boolean>(false); - const toggle = (): void => setShow(!show); - - return ( - <OverlayTrigger - trigger="click" - rootClose - overlay={ - <Popover id="menu-popover"> - <Menu items={items} onItemClicked={() => setShow(false)} /> - </Popover> - } - show={show} - onToggle={toggle} - > - {children} - </OverlayTrigger> - ); -}; diff --git a/FrontEnd/src/views/common/menu/PopupMenu.css b/FrontEnd/src/views/common/menu/PopupMenu.css new file mode 100644 index 00000000..f6654f68 --- /dev/null +++ b/FrontEnd/src/views/common/menu/PopupMenu.css @@ -0,0 +1,6 @@ +.cru-popup-menu-menu-container {
+ z-index: 1040;
+ border-radius: 5px;
+ border: var(--cru-primary-color) 1px solid;
+ background-color: white;
+}
diff --git a/FrontEnd/src/views/common/menu/PopupMenu.tsx b/FrontEnd/src/views/common/menu/PopupMenu.tsx new file mode 100644 index 00000000..d7b81f49 --- /dev/null +++ b/FrontEnd/src/views/common/menu/PopupMenu.tsx @@ -0,0 +1,84 @@ +import classNames from "classnames"; +import React from "react"; +import { createPortal } from "react-dom"; +import { usePopper } from "react-popper"; + +import Menu, { MenuItems } from "./Menu"; + +import "./PopupMenu.css"; + +export interface PopupMenuProps { + items: MenuItems; + children?: React.ReactNode; + containerClassName?: string; + containerStyle?: React.CSSProperties; +} + +const PopupMenu: React.FC<PopupMenuProps> = ({ + items, + children, + containerClassName, + containerStyle, +}) => { + const [show, setShow] = React.useState<boolean>(false); + + const [referenceElement, setReferenceElement] = + React.useState<HTMLDivElement | null>(null); + const [popperElement, setPopperElement] = + React.useState<HTMLDivElement | null>(null); + const { styles, attributes } = usePopper(referenceElement, popperElement); + + React.useEffect(() => { + const handler = (event: MouseEvent): void => { + let element: HTMLElement | null = event.target as HTMLElement; + while (element) { + if (element == referenceElement || element == popperElement) { + return; + } + element = element.parentElement; + } + setShow(false); + }; + document.addEventListener("click", handler); + return () => { + document.removeEventListener("click", handler); + }; + }, [referenceElement, popperElement]); + + return ( + <> + <div + ref={setReferenceElement} + className={classNames( + "cru-popup-menu-trigger-container", + containerClassName + )} + style={containerStyle} + onClick={() => setShow(true)} + > + {children} + </div> + {show + ? createPortal( + <div + ref={setPopperElement} + className="cru-popup-menu-menu-container" + style={styles.popper} + {...attributes.popper} + > + <Menu + items={items} + onItemClicked={() => { + setShow(false); + }} + /> + </div>, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + document.getElementById("portal")! + ) + : null} + </> + ); +}; + +export default PopupMenu; diff --git a/FrontEnd/src/views/common/TabPages.tsx b/FrontEnd/src/views/common/tab/TabPages.tsx index 2b1d91cb..677f558a 100644 --- a/FrontEnd/src/views/common/TabPages.tsx +++ b/FrontEnd/src/views/common/tab/TabPages.tsx @@ -1,18 +1,19 @@ import React from "react"; -import { Nav } from "react-bootstrap"; -import { useTranslation } from "react-i18next"; -import { convertI18nText, I18nText, UiLogicError } from "@/common"; +import { I18nText, UiLogicError } from "@/common"; + +import Tabs from "./Tabs"; export interface TabPage { - id: string; - tabText: I18nText; + name: string; + text: I18nText; page: React.ReactNode; } export interface TabPagesProps { pages: TabPage[]; actions?: React.ReactNode; + dense?: boolean; className?: string; style?: React.CSSProperties; navClassName?: string; @@ -24,6 +25,7 @@ export interface TabPagesProps { const TabPages: React.FC<TabPagesProps> = ({ pages, actions, + dense, className, style, navClassName, @@ -35,11 +37,9 @@ const TabPages: React.FC<TabPagesProps> = ({ throw new UiLogicError("Page list can't be empty."); } - const { t } = useTranslation(); - - const [tab, setTab] = React.useState<string>(pages[0].id); + const [tab, setTab] = React.useState<string>(pages[0].name); - const currentPage = pages.find((p) => p.id === tab); + const currentPage = pages.find((p) => p.name === tab); if (currentPage == null) { throw new UiLogicError("Current tab value is bad."); @@ -47,23 +47,20 @@ const TabPages: React.FC<TabPagesProps> = ({ return ( <div className={className} style={style}> - <Nav variant="tabs" className={navClassName} style={navStyle}> - {pages.map((page) => ( - <Nav.Item key={page.id}> - <Nav.Link - active={tab === page.id} - onClick={() => { - setTab(page.id); - }} - > - {convertI18nText(page.tabText, t)} - </Nav.Link> - </Nav.Item> - ))} - {actions != null && ( - <div className="ms-auto cru-tab-pages-action-area">{actions}</div> - )} - </Nav> + <Tabs + tabs={pages.map((page) => ({ + name: page.name, + text: page.text, + onClick: () => { + setTab(page.name); + }, + }))} + dense={dense} + activeTabName={tab} + className={navClassName} + style={navStyle} + actions={actions} + /> <div className={pageContainerClassName} style={pageContainerStyle}> {currentPage.page} </div> diff --git a/FrontEnd/src/views/common/tab/Tabs.css b/FrontEnd/src/views/common/tab/Tabs.css new file mode 100644 index 00000000..53505a3c --- /dev/null +++ b/FrontEnd/src/views/common/tab/Tabs.css @@ -0,0 +1,31 @@ +.cru-nav {
+ border-bottom: var(--cru-background-2-color) 1px solid;
+ display: flex;
+}
+
+.cru-nav-item {
+ color: var(--cru-primary-color);
+ border: var(--cru-background-2-color) 0.5px solid;
+ border-bottom: none;
+ padding: 0.5em 1.5em;
+ border-top-left-radius: 5px;
+ border-top-right-radius: 5px;
+ transition: all 0.5s;
+ cursor: pointer;
+}
+
+.cru-nav.dense .cru-nav-item {
+ padding: 0.2em 1em;
+}
+
+.cru-nav-item:hover {
+ background-color: var(--cru-background-1-color);
+}
+
+.cru-nav-item:not(.active) {
+ color: var(--cru-primary-r2-color);
+}
+
+.cru-nav-action-area {
+ margin-left: auto;
+}
diff --git a/FrontEnd/src/views/common/tab/Tabs.tsx b/FrontEnd/src/views/common/tab/Tabs.tsx new file mode 100644 index 00000000..701b4073 --- /dev/null +++ b/FrontEnd/src/views/common/tab/Tabs.tsx @@ -0,0 +1,62 @@ +import React from "react"; +import { Link } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import classnames from "classnames"; + +import { convertI18nText, I18nText } from "@/common"; + +import "./Tabs.css"; + +export interface Tab { + name: string; + text: I18nText; + link?: string; + onClick?: () => void; +} + +export interface TabsProps { + activeTabName?: string; + actions?: React.ReactNode; + dense?: boolean; + tabs: Tab[]; + className?: string; + style?: React.CSSProperties; +} + +export default function Tabs(props: TabsProps): React.ReactElement | null { + const { tabs, activeTabName, className, style, dense, actions } = props; + + const { t } = useTranslation(); + + return ( + <div + className={classnames("cru-nav", dense && "dense", className)} + style={style} + > + {tabs.map((tab) => { + const active = activeTabName === tab.name; + const className = classnames("cru-nav-item", active && "active"); + + if (tab.link != null) { + return ( + <Link + key={tab.name} + to={tab.link} + onClick={tab.onClick} + className={className} + > + {convertI18nText(tab.text, t)} + </Link> + ); + } else { + return ( + <span key={tab.name} onClick={tab.onClick} className={className}> + {convertI18nText(tab.text, t)} + </span> + ); + } + })} + <div className="cru-nav-action-area">{actions}</div> + </div> + ); +} diff --git a/FrontEnd/src/views/home/index.css b/FrontEnd/src/views/home/index.css index 516aba52..098bb017 100644 --- a/FrontEnd/src/views/home/index.css +++ b/FrontEnd/src/views/home/index.css @@ -71,3 +71,9 @@ .home-timeline-list-loading-head {
animation: 1s infinite home-timeline-list-loading-head-animation;
}
+
+@media (min-width: 576px) {
+ .home-search {
+ float: right;
+ }
+}
diff --git a/FrontEnd/src/views/home/index.tsx b/FrontEnd/src/views/home/index.tsx index ddb72e76..2e23654e 100644 --- a/FrontEnd/src/views/home/index.tsx +++ b/FrontEnd/src/views/home/index.tsx @@ -56,7 +56,7 @@ const HomeV2: React.FC = () => { return ( <> <SearchInput - className="mx-2 my-3 float-sm-end" + className="mx-2 my-3 home-search" value={navText} onChange={setNavText} onButtonClick={() => { diff --git a/FrontEnd/src/views/login/index.css b/FrontEnd/src/views/login/index.css index dca7054d..aefe57e8 100644 --- a/FrontEnd/src/views/login/index.css +++ b/FrontEnd/src/views/login/index.css @@ -1,3 +1,8 @@ .login-container {
- max-width: 600px;
+ max-width: 25em;
+}
+
+.login-container input[type="text"],
+.login-container input[type="password"] {
+ width: 100%;
}
diff --git a/FrontEnd/src/views/login/index.tsx b/FrontEnd/src/views/login/index.tsx index a39a9972..ed696437 100644 --- a/FrontEnd/src/views/login/index.tsx +++ b/FrontEnd/src/views/login/index.tsx @@ -1,12 +1,11 @@ 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"; +import LoadingButton from "../common/button/LoadingButton"; import "./index.css"; @@ -79,74 +78,76 @@ const LoginPage: React.FC = (_) => { }; return ( - <Container fluid className="login-container mt-2"> - <h1 className="text-center">{t("welcome")}</h1> - <Form> - <Form.Group> - <Form.Label htmlFor="username">{t("user.username")}</Form.Label> - <Form.Control - id="username" - disabled={process} - onChange={(e) => { - setUsername(e.target.value); - setUsernameDirty(true); - }} - value={username} - isInvalid={usernameDirty && username === ""} - /> - {usernameDirty && username === "" && ( - <Form.Control.Feedback type="invalid"> - {t("login.emptyUsername")} - </Form.Control.Feedback> - )} - </Form.Group> - <Form.Group> - <Form.Label htmlFor="password">{t("user.password")}</Form.Label> - <Form.Control - id="password" - type="password" - disabled={process} - onChange={(e) => { - setPassword(e.target.value); - setPasswordDirty(true); - }} - value={password} - onKeyDown={onEnterPressInPassword} - isInvalid={passwordDirty && password === ""} - /> - {passwordDirty && password === "" && ( - <Form.Control.Feedback type="invalid"> - {t("login.emptyPassword")} - </Form.Control.Feedback> - )} - </Form.Group> - <Form.Group> - <Form.Check<"input"> - id="remember-me" - type="checkbox" - checked={rememberMe} - onChange={(e) => { - setRememberMe(e.currentTarget.checked); - }} - label={t("user.rememberMe")} - /> - </Form.Group> - {error ? <p className="text-danger">{t(error)}</p> : null} - <div className="text-end"> - <LoadingButton - loading={process} - variant="primary" - onClick={(e) => { - submit(); - e.preventDefault(); - }} - disabled={username === "" || password === "" ? true : undefined} - > - {t("user.login")} - </LoadingButton> - </div> - </Form> - </Container> + <div className="login-container container-fluid mt-2"> + <h1 className="cru-text-center cru-color-primary">{t("welcome")}</h1> + <div className="cru-operation-dialog-group"> + <label className="cru-operation-dialog-label" htmlFor="username"> + {t("user.username")} + </label> + <input + id="username" + type="text" + disabled={process} + onChange={(e) => { + setUsername(e.target.value); + setUsernameDirty(true); + }} + value={username} + /> + {usernameDirty && username === "" && ( + <div className="cru-operation-dialog-error-text"> + {t("login.emptyUsername")} + </div> + )} + </div> + <div className="cru-operation-dialog-group"> + <label className="cru-operation-dialog-label" htmlFor="password"> + {t("user.password")} + </label> + <input + id="password" + type="password" + disabled={process} + onChange={(e) => { + setPassword(e.target.value); + setPasswordDirty(true); + }} + value={password} + onKeyDown={onEnterPressInPassword} + /> + {passwordDirty && password === "" && ( + <div className="cru-operation-dialog-error-text"> + {t("login.emptyPassword")} + </div> + )} + </div> + <div className="cru-operation-dialog-group"> + <input + id="remember-me" + type="checkbox" + checked={rememberMe} + onChange={(e) => { + setRememberMe(e.currentTarget.checked); + }} + /> + <label className="cru-operation-dialog-inline-label"> + {t("user.rememberMe")} + </label> + </div> + {error ? <p className="text-danger">{t(error)}</p> : null} + <div className="cru-text-end"> + <LoadingButton + loading={process} + onClick={(e) => { + submit(); + e.preventDefault(); + }} + disabled={username === "" || password === "" ? true : undefined} + > + {t("user.login")} + </LoadingButton> + </div> + </div> ); }; diff --git a/FrontEnd/src/views/search/index.tsx b/FrontEnd/src/views/search/index.tsx index e849a95d..509fd8c0 100644 --- a/FrontEnd/src/views/search/index.tsx +++ b/FrontEnd/src/views/search/index.tsx @@ -1,6 +1,5 @@ import React from "react"; import { useTranslation } from "react-i18next"; -import { Container, Row } from "react-bootstrap"; import { useHistory, useLocation } from "react-router"; import { Link } from "react-router-dom"; @@ -80,8 +79,8 @@ const SearchPage: React.FC = () => { }, [queryParam, forceResearchKey]); return ( - <Container className="my-3"> - <Row className="justify-content-center"> + <div className="container my-3"> + <div className="row justify-content-center"> <SearchInput className="col-12 col-sm-9 col-md-6" value={searchText} @@ -95,7 +94,7 @@ const SearchPage: React.FC = () => { } }} /> - </Row> + </div> {(() => { switch (state) { case "init": { @@ -123,7 +122,7 @@ const SearchPage: React.FC = () => { } } })()} - </Container> + </div> ); }; diff --git a/FrontEnd/src/views/settings/ChangeAvatarDialog.tsx b/FrontEnd/src/views/settings/ChangeAvatarDialog.tsx index c4f6f492..c33687df 100644 --- a/FrontEnd/src/views/settings/ChangeAvatarDialog.tsx +++ b/FrontEnd/src/views/settings/ChangeAvatarDialog.tsx @@ -1,7 +1,6 @@ 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"; @@ -10,6 +9,8 @@ import { useUserLoggedIn } from "@/services/user"; import { getHttpUserClient } from "@/http/user"; import ImageCropper, { Clip, applyClipToImage } from "../common/ImageCropper"; +import Button from "../common/button/Button"; +import Dialog from "../common/dailog/Dialog"; export interface ChangeAvatarDialogProps { open: boolean; @@ -148,36 +149,49 @@ const ChangeAvatarDialog: React.FC<ChangeAvatarDialogProps> = (props) => { throw new UiLogicError(); } return ( - <Row className="justify-content-center"> - <img - className="change-avatar-img" - src={resultUrl} - alt={t("settings.dialogChangeAvatar.previewImgAlt")} - /> - </Row> + <div className="row justify-content-center"> + <div className="col col-auto"> + <img + className="change-avatar-img" + src={resultUrl} + alt={t("settings.dialogChangeAvatar.previewImgAlt")} + /> + </div> + </div> ); }; return ( - <Modal show={props.open} onHide={close}> - <Modal.Header> - <Modal.Title> {t("settings.dialogChangeAvatar.title")}</Modal.Title> - </Modal.Header> + <Dialog open={props.open} onClose={close}> + <h3 className="cru-color-primary"> + {t("settings.dialogChangeAvatar.title")} + </h3> + <hr /> {(() => { if (state === "select") { return ( <> - <Modal.Body className="container"> - <Row>{t("settings.dialogChangeAvatar.prompt.select")}</Row> - <Row> - <input type="file" accept="image/*" onChange={onSelectFile} /> - </Row> - </Modal.Body> - <Modal.Footer> - <Button variant="secondary" onClick={close}> - {t("operationDialog.cancel")} - </Button> - </Modal.Footer> + <div className="container"> + <div className="row"> + {t("settings.dialogChangeAvatar.prompt.select")} + </div> + <div className="row"> + <input + className="px-0" + type="file" + accept="image/*" + onChange={onSelectFile} + /> + </div> + </div> + <hr /> + <div className="cru-dialog-bottom-area"> + <Button + text="operationDialog.cancel" + color="secondary" + onClick={close} + /> + </div> </> ); } else if (state === "crop") { @@ -186,119 +200,154 @@ const ChangeAvatarDialog: React.FC<ChangeAvatarDialogProps> = (props) => { } return ( <> - <Modal.Body className="container"> - <Row className="justify-content-center"> + <div className="container"> + <div className="row justify-content-center"> <ImageCropper clip={clip} onChange={setClip} imageUrl={fileUrl} imageElementCallback={setCropImgElement} /> - </Row> - <Row>{t("settings.dialogChangeAvatar.prompt.crop")}</Row> - </Modal.Body> - <Modal.Footer> - <Button variant="secondary" onClick={close}> - {t("operationDialog.cancel")} - </Button> - <Button variant="secondary" onClick={onCropPrevious}> - {t("operationDialog.previousStep")} - </Button> + </div> + <div className="row"> + {t("settings.dialogChangeAvatar.prompt.crop")} + </div> + </div> + <hr /> + <div className="cru-dialog-bottom-area"> + <Button + text="operationDialog.cancel" + color="secondary" + outline + onClick={close} + /> + <Button + text="operationDialog.previousStep" + color="secondary" + outline + onClick={onCropPrevious} + /> <Button + text="operationDialog.nextStep" color="primary" onClick={onCropNext} disabled={ cropImgElement == null || clip == null || clip.width === 0 } - > - {t("operationDialog.nextStep")} - </Button> - </Modal.Footer> + /> + </div> </> ); } else if (state === "processcrop") { return ( <> - <Modal.Body className="container"> - <Row> + <div className="container"> + <div className="row"> {t("settings.dialogChangeAvatar.prompt.processingCrop")} - </Row> - </Modal.Body> - <Modal.Footer> - <Button variant="secondary" onClick={close}> - {t("operationDialog.cancel")} - </Button> - <Button variant="secondary" onClick={onPreviewPrevious}> - {t("operationDialog.previousStep")} - </Button> - </Modal.Footer> + </div> + </div> + <hr /> + <div className="cru-dialog-bottom-area"> + <Button + text="operationDialog.cancel" + color="secondary" + onClick={close} + outline + /> + <Button + text="operationDialog.previousStep" + color="secondary" + onClick={onPreviewPrevious} + outline + /> + </div> </> ); } else if (state === "preview") { return ( <> - <Modal.Body className="container"> + <div className="container"> {createPreviewRow()} - <Row>{t("settings.dialogChangeAvatar.prompt.preview")}</Row> - </Modal.Body> - <Modal.Footer> - <Button variant="secondary" onClick={close}> - {t("operationDialog.cancel")} - </Button> - <Button variant="secondary" onClick={onPreviewPrevious}> - {t("operationDialog.previousStep")} - </Button> - <Button variant="primary" onClick={upload}> - {t("settings.dialogChangeAvatar.upload")} - </Button> - </Modal.Footer> + <div className="row"> + {t("settings.dialogChangeAvatar.prompt.preview")} + </div> + </div> + <hr /> + <div className="cru-dialog-bottom-area"> + <Button + text="operationDialog.cancel" + color="secondary" + outline + onClick={close} + /> + <Button + text="operationDialog.previousStep" + color="secondary" + outline + onClick={onPreviewPrevious} + /> + <Button + text="settings.dialogChangeAvatar.upload" + color="primary" + onClick={upload} + /> + </div> </> ); } else if (state === "uploading") { return ( <> - <Modal.Body className="container"> + <div className="container"> {createPreviewRow()} - <Row>{t("settings.dialogChangeAvatar.prompt.uploading")}</Row> - </Modal.Body> - <Modal.Footer></Modal.Footer> + <div className="row"> + {t("settings.dialogChangeAvatar.prompt.uploading")} + </div> + </div> </> ); } else if (state === "success") { return ( <> - <Modal.Body className="container"> - <Row className="p-4 text-success"> + <div className="container"> + <div className="row p-4 text-success"> {t("operationDialog.success")} - </Row> - </Modal.Body> - <Modal.Footer> - <Button variant="success" onClick={close}> - {t("operationDialog.ok")} - </Button> - </Modal.Footer> + </div> + </div> + <hr /> + <div className="cru-dialog-bottom-area"> + <Button + text="operationDialog.ok" + color="success" + onClick={close} + /> + </div> </> ); } else { return ( <> - <Modal.Body className="container"> + <div 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> + <div className="row text-danger">{trueMessage}</div> + </div> + <hr /> + <div> + <Button + text="operationDialog.cancel" + color="secondary" + onClick={close} + /> + <Button + text="operationDialog.retry" + color="primary" + onClick={upload} + /> + </div> </> ); } })()} - </Modal> + </Dialog> ); }; diff --git a/FrontEnd/src/views/settings/ChangeNicknameDialog.tsx b/FrontEnd/src/views/settings/ChangeNicknameDialog.tsx index 4b44cdd6..605796ca 100644 --- a/FrontEnd/src/views/settings/ChangeNicknameDialog.tsx +++ b/FrontEnd/src/views/settings/ChangeNicknameDialog.tsx @@ -2,7 +2,7 @@ import { getHttpUserClient } from "@/http/user"; import { useUserLoggedIn } from "@/services/user"; import React from "react"; -import OperationDialog from "../common/OperationDialog"; +import OperationDialog from "../common/dailog/OperationDialog"; export interface ChangeNicknameDialogProps { open: boolean; @@ -24,7 +24,7 @@ const ChangeNicknameDialog: React.FC<ChangeNicknameDialogProps> = (props) => { nickname: newNickname, }); }} - close={props.close} + onClose={props.close} /> ); }; diff --git a/FrontEnd/src/views/settings/ChangePasswordDialog.tsx b/FrontEnd/src/views/settings/ChangePasswordDialog.tsx index 21eeeb09..944fdaed 100644 --- a/FrontEnd/src/views/settings/ChangePasswordDialog.tsx +++ b/FrontEnd/src/views/settings/ChangePasswordDialog.tsx @@ -3,7 +3,7 @@ import { useHistory } from "react-router"; import { userService } from "@/services/user"; -import OperationDialog from "../common/OperationDialog"; +import OperationDialog from "../common/dailog/OperationDialog"; export interface ChangePasswordDialogProps { open: boolean; @@ -55,7 +55,7 @@ const ChangePasswordDialog: React.FC<ChangePasswordDialogProps> = (props) => { await userService.changePassword(oldPassword, newPassword); setRedirect(true); }} - close={() => { + onClose={() => { props.close(); if (redirect) { history.push("/login"); diff --git a/FrontEnd/src/views/settings/index.tsx b/FrontEnd/src/views/settings/index.tsx index 840bb7e8..69a74327 100644 --- a/FrontEnd/src/views/settings/index.tsx +++ b/FrontEnd/src/views/settings/index.tsx @@ -1,43 +1,17 @@ import React, { useState } from "react"; import { useHistory } from "react-router"; import { useTranslation } from "react-i18next"; -import { Container, Form, Row, Col, Button, Modal } from "react-bootstrap"; import { useUser, userService } from "@/services/user"; import ChangePasswordDialog from "./ChangePasswordDialog"; import ChangeAvatarDialog from "./ChangeAvatarDialog"; import ChangeNicknameDialog from "./ChangeNicknameDialog"; +import ConfirmDialog from "../common/dailog/ConfirmDialog"; import Card from "../common/Card"; import "./index.css"; -const ConfirmLogoutDialog: React.FC<{ - onClose: () => void; - onConfirm: () => void; -}> = ({ onClose, onConfirm }) => { - const { t } = useTranslation(); - - return ( - <Modal show centered onHide={onClose}> - <Modal.Header> - <Modal.Title className="text-danger"> - {t("settings.dialogConfirmLogout.title")} - </Modal.Title> - </Modal.Header> - <Modal.Body>{t("settings.dialogConfirmLogout.prompt")}</Modal.Body> - <Modal.Footer> - <Button variant="secondary" onClick={onClose}> - {t("operationDialog.cancel")} - </Button> - <Button variant="danger" onClick={onConfirm}> - {t("operationDialog.confirm")} - </Button> - </Modal.Footer> - </Modal> - ); -}; - const SettingsPage: React.FC = (_) => { const { i18n, t } = useTranslation(); const user = useUser(); @@ -51,10 +25,10 @@ const SettingsPage: React.FC = (_) => { return ( <> - <Container> + <div className="container"> {user ? ( <Card className="my-3 py-3"> - <h3 className="px-3 mb-3 text-primary"> + <h3 className="px-3 mb-3 cru-color-primary"> {t("settings.subheaders.account")} </h3> <div @@ -70,13 +44,13 @@ const SettingsPage: React.FC = (_) => { {t("settings.changeNickname")} </div> <div - className="settings-item clickable text-danger" + className="settings-item clickable cru-color-danger" onClick={() => setDialog("changepassword")} > {t("settings.changePassword")} </div> <div - className="settings-item clickable text-danger" + className="settings-item clickable cru-color-danger" onClick={() => { setDialog("logout"); }} @@ -86,19 +60,18 @@ const SettingsPage: React.FC = (_) => { </Card> ) : null} <Card className="my-3 py-3"> - <h3 className="px-3 mb-3 text-primary"> + <h3 className="px-3 mb-3 cru-color-primary"> {t("settings.subheaders.customization")} </h3> - <Row className="settings-item first mx-0"> - <Col xs="12" sm="auto"> + <div className="row settings-item first mx-0"> + <div className="col col-12 col-sm-auto"> <div>{t("settings.languagePrimary")}</div> - <small className="d-block text-secondary"> + <small className="d-block cru-color-secondary"> {t("settings.languageSecondary")} </small> - </Col> - <Col xs="auto" className="ms-auto"> - <Form.Control - as="select" + </div> + <div className="col col-12 col-sm-auto"> + <select value={language} onChange={(e) => { void i18n.changeLanguage(e.target.value); @@ -106,19 +79,22 @@ const SettingsPage: React.FC = (_) => { > <option value="zh">中文</option> <option value="en">English</option> - </Form.Control> - </Col> - </Row> + </select> + </div> + </div> </Card> - </Container> + </div> {(() => { switch (dialog) { case "changepassword": return <ChangePasswordDialog open close={() => setDialog(null)} />; case "logout": return ( - <ConfirmLogoutDialog + <ConfirmDialog + title="settings.dialogConfirmLogout.title" + body="settings.dialogConfirmLogout.prompt" onClose={() => setDialog(null)} + open onConfirm={() => { void userService.logout().then(() => { history.push("/"); diff --git a/FrontEnd/src/views/timeline-common/CollapseButton.tsx b/FrontEnd/src/views/timeline-common/CollapseButton.tsx index 12a3b710..31976228 100644 --- a/FrontEnd/src/views/timeline-common/CollapseButton.tsx +++ b/FrontEnd/src/views/timeline-common/CollapseButton.tsx @@ -12,7 +12,7 @@ const CollapseButton: React.FC<{ onClick={onClick} className={classnames( collapse ? "bi-arrows-angle-expand" : "bi-arrows-angle-contract", - "text-primary icon-button", + "cru-color-primary icon-button", className )} style={style} diff --git a/FrontEnd/src/views/timeline-common/MarkdownPostEdit.css b/FrontEnd/src/views/timeline-common/MarkdownPostEdit.css new file mode 100644 index 00000000..e36be992 --- /dev/null +++ b/FrontEnd/src/views/timeline-common/MarkdownPostEdit.css @@ -0,0 +1,21 @@ +.timeline-markdown-post-edit-page {
+ overflow: auto;
+ max-height: 300px;
+}
+
+.timeline-markdown-post-edit-image-container {
+ position: relative;
+ text-align: center;
+ margin-bottom: 1em;
+}
+
+.timeline-markdown-post-edit-image {
+ max-width: 100%;
+ max-height: 200px;
+}
+
+.timeline-markdown-post-edit-image-delete-button {
+ position: absolute;
+ right: 10px;
+ top: 2px;
+}
diff --git a/FrontEnd/src/views/timeline-common/MarkdownPostEdit.tsx b/FrontEnd/src/views/timeline-common/MarkdownPostEdit.tsx index 005da933..6d0fbedd 100644 --- a/FrontEnd/src/views/timeline-common/MarkdownPostEdit.tsx +++ b/FrontEnd/src/views/timeline-common/MarkdownPostEdit.tsx @@ -1,15 +1,19 @@ +/* eslint-disable react/jsx-no-undef */ import React from "react"; import classnames from "classnames"; -import { Form, Spinner } from "react-bootstrap"; import { useTranslation } from "react-i18next"; import { Prompt } from "react-router"; import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline"; -import FlatButton from "../common/button/FlatButton"; -import TabPages from "../common/TabPages"; import TimelinePostBuilder from "@/services/TimelinePostBuilder"; -import ConfirmDialog from "../common/ConfirmDialog"; + +import FlatButton from "../common/button/FlatButton"; +import TabPages from "../common/tab/TabPages"; +import ConfirmDialog from "../common/dailog/ConfirmDialog"; +import Spinner from "../common/Spinner"; + +import "./MarkdownPostEdit.css"; export interface MarkdownPostEditProps { timeline: string; @@ -100,9 +104,10 @@ const MarkdownPostEdit: React.FC<MarkdownPostEditProps> = ({ className={className} style={style} pageContainerClassName="py-2" + dense actions={ process ? ( - <Spinner variant="primary" animation="border" size="sm" /> + <Spinner /> ) : ( <> <FlatButton @@ -123,13 +128,13 @@ const MarkdownPostEdit: React.FC<MarkdownPostEditProps> = ({ } pages={[ { - id: "text", - tabText: "edit", + name: "text", + text: "edit", page: ( - <Form.Control - as="textarea" + <textarea value={text} disabled={process} + className="cru-fill-parent" onChange={(event) => { getBuilder().setMarkdownText(event.currentTarget.value); }} @@ -137,8 +142,8 @@ const MarkdownPostEdit: React.FC<MarkdownPostEditProps> = ({ ), }, { - id: "images", - tabText: "image", + name: "images", + text: "image", page: ( <div className="timeline-markdown-post-edit-page"> {images.map((image, index) => ( @@ -161,7 +166,7 @@ const MarkdownPostEdit: React.FC<MarkdownPostEditProps> = ({ /> </div> ))} - <Form.Control + <input type="file" accept="image/jpeg,image/jpg,image/png,image/gif,image/webp" onChange={(event: React.ChangeEvent<HTMLInputElement>) => { @@ -176,8 +181,8 @@ const MarkdownPostEdit: React.FC<MarkdownPostEditProps> = ({ ), }, { - id: "preview", - tabText: "preview", + name: "preview", + text: "preview", page: ( <div className="markdown-container timeline-markdown-post-edit-page" @@ -191,6 +196,7 @@ const MarkdownPostEdit: React.FC<MarkdownPostEditProps> = ({ <ConfirmDialog onClose={() => setShowLeaveConfirmDialog(false)} onConfirm={onClose} + open title="timeline.dropDraft" body="timeline.confirmLeave" /> diff --git a/FrontEnd/src/views/timeline-common/PostPropertyChangeDialog.tsx b/FrontEnd/src/views/timeline-common/PostPropertyChangeDialog.tsx index 001e52d7..988124b6 100644 --- a/FrontEnd/src/views/timeline-common/PostPropertyChangeDialog.tsx +++ b/FrontEnd/src/views/timeline-common/PostPropertyChangeDialog.tsx @@ -2,7 +2,7 @@ import React from "react"; import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline"; -import OperationDialog from "../common/OperationDialog"; +import OperationDialog from "../common/dailog/OperationDialog"; function PostPropertyChangeDialog(props: { onClose: () => void; @@ -14,7 +14,7 @@ function PostPropertyChangeDialog(props: { return ( <OperationDialog title="timeline.changePostPropertyDialog.title" - close={onClose} + onClose={onClose} open inputScheme={[ { diff --git a/FrontEnd/src/views/timeline-common/TimelineMember.css b/FrontEnd/src/views/timeline-common/TimelineMember.css new file mode 100644 index 00000000..adb78764 --- /dev/null +++ b/FrontEnd/src/views/timeline-common/TimelineMember.css @@ -0,0 +1,8 @@ +.timeline-member-item {
+ border: var(--cru-background-1-color) solid;
+ border-width: 0.5px 1px;
+}
+
+.timeline-member-item > div {
+ padding: 0.5em;
+}
diff --git a/FrontEnd/src/views/timeline-common/TimelineMember.tsx b/FrontEnd/src/views/timeline-common/TimelineMember.tsx index 299d6a53..0ebecbb9 100644 --- a/FrontEnd/src/views/timeline-common/TimelineMember.tsx +++ b/FrontEnd/src/views/timeline-common/TimelineMember.tsx @@ -1,49 +1,47 @@ import React, { useState } from "react"; import { useTranslation } from "react-i18next"; -import { Container, ListGroup, Modal, Row, Col, Button } from "react-bootstrap"; import { convertI18nText, I18nText } from "@/common"; import { HttpUser } from "@/http/user"; import { getHttpSearchClient } from "@/http/search"; +import { getHttpTimelineClient, HttpTimelineInfo } from "@/http/timeline"; import SearchInput from "../common/SearchInput"; import UserAvatar from "../common/user/UserAvatar"; -import { getHttpTimelineClient, HttpTimelineInfo } from "@/http/timeline"; +import Button from "../common/button/Button"; +import Dialog from "../common/dailog/Dialog"; + +import "./TimelineMember.css"; const TimelineMemberItem: React.FC<{ user: HttpUser; add?: boolean; onAction?: (username: string) => void; }> = ({ user, add, onAction }) => { - const { t } = useTranslation(); - return ( - <ListGroup.Item className="container"> - <Row> - <Col xs="auto"> - <UserAvatar username={user.username} className="avatar small" /> - </Col> - <Col> - <Row>{user.nickname}</Row> - <Row> - <small>{"@" + user.username}</small> - </Row> - </Col> + <div className="container timeline-member-item"> + <div className="row"> + <div className="col col-auto"> + <UserAvatar username={user.username} className="cru-avatar small" /> + </div> + <div className="col"> + <div className="row">{user.nickname}</div> + <small className="row">{"@" + user.username}</small> + </div> {onAction ? ( - <Col xs="auto"> + <div className="col col-auto"> <Button - variant={add ? "success" : "danger"} + text={`timeline.member.${add ? "add" : "remove"}`} + color={add ? "success" : "danger"} onClick={() => { onAction(user.username); }} - > - {t(`timeline.member.${add ? "add" : "remove"}`)} - </Button> - </Col> + /> + </div> ) : null} - </Row> - </ListGroup.Item> + </div> + </div> ); }; @@ -110,7 +108,7 @@ const TimelineMemberUserSearch: React.FC<{ return <div>{t("timeline.member.noUserAvailableToAdd")}</div>; } else { return ( - <ListGroup className="mt-2"> + <div className="mt-2"> {users.map((user) => ( <TimelineMemberItem key={user.username} @@ -127,12 +125,12 @@ const TimelineMemberUserSearch: React.FC<{ }} /> ))} - </ListGroup> + </div> ); } } else if (userSearchState.type === "error") { return ( - <div className="text-danger"> + <div className="cru-color-danger"> {convertI18nText(userSearchState.data, t)} </div> ); @@ -152,8 +150,8 @@ const TimelineMember: React.FC<TimelineMemberProps> = (props) => { const members = [timeline.owner, ...timeline.members]; return ( - <Container className="px-4 py-3"> - <ListGroup> + <div className="container px-4 py-3"> + <div> {members.map((member, index) => ( <TimelineMemberItem key={member.username} @@ -169,11 +167,11 @@ const TimelineMember: React.FC<TimelineMemberProps> = (props) => { } /> ))} - </ListGroup> + </div> {timeline.manageable ? ( <TimelineMemberUserSearch timeline={timeline} onChange={onChange} /> ) : null} - </Container> + </div> ); }; @@ -188,8 +186,8 @@ export const TimelineMemberDialog: React.FC<TimelineMemberDialogProps> = ( props ) => { return ( - <Modal show centered onHide={props.onClose}> + <Dialog open onClose={props.onClose}> <TimelineMember {...props} /> - </Modal> + </Dialog> ); }; diff --git a/FrontEnd/src/views/timeline-common/TimelinePageCardTemplate.tsx b/FrontEnd/src/views/timeline-common/TimelinePageCardTemplate.tsx index 851dfa55..5c2fb275 100644 --- a/FrontEnd/src/views/timeline-common/TimelinePageCardTemplate.tsx +++ b/FrontEnd/src/views/timeline-common/TimelinePageCardTemplate.tsx @@ -17,12 +17,13 @@ import CollapseButton from "./CollapseButton"; import { TimelineMemberDialog } from "./TimelineMember"; import TimelinePropertyChangeDialog from "./TimelinePropertyChangeDialog"; import ConnectionStatusBadge from "./ConnectionStatusBadge"; -import { MenuItems, PopupMenu } from "../common/Menu"; -import FullPage from "../common/FullPage"; +import { MenuItems } from "../common/menu/Menu"; +import PopupMenu from "../common/menu/PopupMenu"; +import FullPageDialog from "../common/dailog/FullPageDialog"; import Card from "../common/Card"; export interface TimelineCardTemplateProps extends TimelinePageCardProps { - infoArea: React.ReactElement; + infoArea: React.ReactNode; manageItems?: MenuItems; dialog: string | "property" | "member" | null; setDialog: (dialog: "property" | "member" | null) => void; @@ -53,11 +54,11 @@ const TimelinePageCardTemplate: React.FC<TimelineCardTemplateProps> = ({ <small className="mt-1 d-block"> {t(timelineVisibilityTooltipTranslationMap[timeline.visibility])} </small> - <div className="text-end mt-2"> + <div className="mt-2 cru-text-end"> <i className={classnames( timeline.isHighlight ? "bi-star-fill" : "bi-star", - "icon-button text-yellow me-3" + "icon-button cru-color-primary me-3" )} onClick={ user?.hasHighlightTimelineAdministrationPermission @@ -80,7 +81,7 @@ const TimelinePageCardTemplate: React.FC<TimelineCardTemplateProps> = ({ <i className={classnames( timeline.isBookmark ? "bi-bookmark-fill" : "bi-bookmark", - "icon-button text-yellow me-3" + "icon-button cru-color-primary me-3" )} onClick={() => { getHttpBookmarkClient() @@ -97,12 +98,12 @@ const TimelinePageCardTemplate: React.FC<TimelineCardTemplateProps> = ({ /> ) : null} <i - className={"icon-button bi-people text-primary me-3"} + className={"icon-button bi-people cru-color-primary me-3"} onClick={() => setDialog("member")} /> {manageItems != null ? ( - <PopupMenu items={manageItems}> - <i className="icon-button bi-three-dots-vertical text-primary" /> + <PopupMenu items={manageItems} containerClassName="d-inline"> + <i className="icon-button bi-three-dots-vertical cru-color-primary" /> </PopupMenu> ) : null} </div> @@ -111,24 +112,21 @@ const TimelinePageCardTemplate: React.FC<TimelineCardTemplateProps> = ({ return ( <> - <Card - className={classnames("p-2 clearfix", className)} - style={{ zIndex: collapse ? 1029 : 1031 }} - > - <div className="float-end d-flex align-items-center"> + <Card className={classnames("p-2 cru-clearfix", className)}> + <div className="cru-float-right ms-3 d-flex align-items-center"> <ConnectionStatusBadge status={connectionStatus} className="me-2" /> <CollapseButton collapse={collapse} onClick={toggleCollapse} /> </div> {isSmallScreen ? ( - <FullPage + <FullPageDialog onBack={toggleCollapse} show={!collapse} contentContainerClassName="p-2" > {content} - </FullPage> + </FullPageDialog> ) : ( - <div style={{ display: collapse ? "none" : "block" }}>{content}</div> + <div style={{ display: collapse ? "none" : "inline" }}>{content}</div> )} </Card> {(() => { diff --git a/FrontEnd/src/views/timeline-common/TimelinePageTemplate.tsx b/FrontEnd/src/views/timeline-common/TimelinePageTemplate.tsx index 6f032eae..ea6e8d40 100644 --- a/FrontEnd/src/views/timeline-common/TimelinePageTemplate.tsx +++ b/FrontEnd/src/views/timeline-common/TimelinePageTemplate.tsx @@ -1,5 +1,4 @@ import React from "react"; -import { Container } from "react-bootstrap"; import { HubConnectionState } from "@microsoft/signalr"; import { HttpTimelineInfo } from "@/http/timeline"; @@ -75,7 +74,7 @@ const TimelinePageTemplate: React.FC<TimelinePageTemplateProps> = (props) => { connectionStatus={connectionStatus} /> ) : null} - <Container> + <div className="container"> <Timeline timelineName={timelineName} reloadKey={reloadKey} @@ -83,7 +82,7 @@ const TimelinePageTemplate: React.FC<TimelinePageTemplateProps> = (props) => { onTimelineLoaded={(t) => setTimeline(t)} onConnectionStateChanged={setConnectionStatus} /> - </Container> + </div> </> ); }; diff --git a/FrontEnd/src/views/timeline-common/TimelinePostDeleteConfirmDialog.tsx b/FrontEnd/src/views/timeline-common/TimelinePostDeleteConfirmDialog.tsx deleted file mode 100644 index b2c7a470..00000000 --- a/FrontEnd/src/views/timeline-common/TimelinePostDeleteConfirmDialog.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import React from "react"; -import { Modal, Button } from "react-bootstrap"; -import { useTranslation } from "react-i18next"; - -const TimelinePostDeleteConfirmDialog: React.FC<{ - onClose: () => void; - onConfirm: () => void; -}> = ({ onClose, onConfirm }) => { - const { t } = useTranslation(); - - return ( - <Modal onHide={onClose} show centered> - <Modal.Header> - <Modal.Title className="text-danger"> - {t("timeline.post.deleteDialog.title")} - </Modal.Title> - </Modal.Header> - <Modal.Body>{t("timeline.post.deleteDialog.prompt")}</Modal.Body> - <Modal.Footer> - <Button variant="secondary" onClick={onClose}> - {t("operationDialog.cancel")} - </Button> - <Button - variant="danger" - onClick={() => { - onConfirm(); - onClose(); - }} - > - {t("operationDialog.confirm")} - </Button> - </Modal.Footer> - </Modal> - ); -}; - -export default TimelinePostDeleteConfirmDialog; diff --git a/FrontEnd/src/views/timeline-common/TimelinePostEdit.css b/FrontEnd/src/views/timeline-common/TimelinePostEdit.css index 0c7deaa2..4ce98383 100644 --- a/FrontEnd/src/views/timeline-common/TimelinePostEdit.css +++ b/FrontEnd/src/views/timeline-common/TimelinePostEdit.css @@ -9,7 +9,7 @@ }
.timeline-post-edit {
- position: sticky;
+ position: sticky !important;
bottom: 0;
z-index: 1;
}
@@ -18,25 +18,3 @@ max-width: 100px;
max-height: 100px;
}
-
-.timeline-markdown-post-edit-page {
- overflow: scroll;
- max-height: 300px;
-}
-
-.timeline-markdown-post-edit-image-container {
- position: relative;
- text-align: center;
- margin-bottom: 1em;
-}
-
-.timeline-markdown-post-edit-image {
- max-width: 100%;
- max-height: 200px;
-}
-
-.timeline-markdown-post-edit-image-delete-button {
- position: absolute;
- right: 10px;
- top: 2px;
-}
diff --git a/FrontEnd/src/views/timeline-common/TimelinePostEdit.tsx b/FrontEnd/src/views/timeline-common/TimelinePostEdit.tsx index 14cd50d4..9c48c7c8 100644 --- a/FrontEnd/src/views/timeline-common/TimelinePostEdit.tsx +++ b/FrontEnd/src/views/timeline-common/TimelinePostEdit.tsx @@ -1,7 +1,6 @@ import React from "react"; import classnames from "classnames"; import { useTranslation } from "react-i18next"; -import { Row, Col, Form } from "react-bootstrap"; import { UiLogicError } from "@/common"; @@ -16,8 +15,8 @@ import { pushAlert } from "@/services/alert"; import { base64 } from "@/http/common"; import BlobImage from "../common/BlobImage"; -import LoadingButton from "../common/LoadingButton"; -import { PopupMenu } from "../common/Menu"; +import LoadingButton from "../common/button/LoadingButton"; +import PopupMenu from "../common/menu/PopupMenu"; import Card from "../common/Card"; import MarkdownPostEdit from "./MarkdownPostEdit"; import TimelineLine from "./TimelineLine"; @@ -36,8 +35,7 @@ const TimelinePostEditText: React.FC<TimelinePostEditTextProps> = (props) => { const { text, disabled, onChange, className, style } = props; return ( - <Form.Control - as="textarea" + <textarea value={text} disabled={disabled} onChange={(event) => { @@ -81,7 +79,7 @@ const TimelinePostEditImage: React.FC<TimelinePostEditImageProps> = (props) => { return ( <> - <Form.Control + <input type="file" onChange={onInputChange} accept="image/*" @@ -205,20 +203,20 @@ const TimelinePostEdit: React.FC<TimelinePostEditProps> = (props) => { <Card className="timeline-item-card"> {showMarkdown ? ( <MarkdownPostEdit - className="w-100" + className="cru-fill-parent" onClose={() => setShowMarkdown(false)} timeline={timeline.name} onPosted={onPosted} onPostError={onPostError} /> ) : ( - <Row> - <Col className="px-1 py-1"> + <div className="row"> + <div className="col px-1 py-1"> {(() => { if (kind === "text") { return ( <TimelinePostEditText - className="w-100 h-100 timeline-post-edit" + className="cru-fill-parent timeline-post-edit" text={text} disabled={process} onChange={(t) => { @@ -239,9 +237,9 @@ const TimelinePostEdit: React.FC<TimelinePostEditProps> = (props) => { ); } })()} - </Col> - <Col xs="auto" className="align-self-end m-1"> - <div className="d-block text-center mt-1 mb-2"> + </div> + <div className="col col-auto align-self-end m-1"> + <div className="d-block cru-text-center mt-1 mb-2"> <PopupMenu items={(["text", "image", "markdown"] as const).map( (kind) => ({ @@ -267,15 +265,14 @@ const TimelinePostEdit: React.FC<TimelinePostEditProps> = (props) => { </PopupMenu> </div> <LoadingButton - variant="primary" onClick={onSend} disabled={!canSend} loading={process} > {t("timeline.send")} </LoadingButton> - </Col> - </Row> + </div> + </div> )} </Card> </div> diff --git a/FrontEnd/src/views/timeline-common/TimelinePostView.tsx b/FrontEnd/src/views/timeline-common/TimelinePostView.tsx index 7b16e898..652ff9c9 100644 --- a/FrontEnd/src/views/timeline-common/TimelinePostView.tsx +++ b/FrontEnd/src/views/timeline-common/TimelinePostView.tsx @@ -9,9 +9,9 @@ import { pushAlert } from "@/services/alert"; import UserAvatar from "../common/user/UserAvatar"; import Card from "../common/Card"; import FlatButton from "../common/button/FlatButton"; +import ConfirmDialog from "../common/dailog/ConfirmDialog"; import TimelineLine from "./TimelineLine"; import TimelinePostContentView from "./TimelinePostContentView"; -import TimelinePostDeleteConfirmDialog from "./TimelinePostDeleteConfirmDialog"; import PostPropertyChangeDialog from "./PostPropertyChangeDialog"; export interface TimelinePostViewProps { @@ -64,7 +64,7 @@ const TimelinePostView: React.FC<TimelinePostViewProps> = (props) => { > {post.editable ? ( <i - className="bi-chevron-down icon-button primary-enhance float-end" + className="bi-chevron-down icon-button primary-enhance cru-float-right" onClick={(e) => { setOperationMaskVisible(true); e.stopPropagation(); @@ -116,7 +116,9 @@ const TimelinePostView: React.FC<TimelinePostViewProps> = (props) => { ) : null} </Card> {dialog === "delete" ? ( - <TimelinePostDeleteConfirmDialog + <ConfirmDialog + title="timeline.post.deleteDialog.title" + body="timeline.post.deleteDialog.prompt" onClose={() => { setDialog(null); setOperationMaskVisible(false); diff --git a/FrontEnd/src/views/timeline-common/TimelinePropertyChangeDialog.tsx b/FrontEnd/src/views/timeline-common/TimelinePropertyChangeDialog.tsx index 70f72025..64daa19b 100644 --- a/FrontEnd/src/views/timeline-common/TimelinePropertyChangeDialog.tsx +++ b/FrontEnd/src/views/timeline-common/TimelinePropertyChangeDialog.tsx @@ -8,7 +8,7 @@ import { TimelineVisibility, } from "@/http/timeline"; -import OperationDialog from "../common/OperationDialog"; +import OperationDialog from "../common/dailog/OperationDialog"; export interface TimelinePropertyChangeDialogProps { open: boolean; @@ -60,7 +60,7 @@ const TimelinePropertyChangeDialog: React.FC<TimelinePropertyChangeDialogProps> ] as const } open={props.open} - close={props.close} + onClose={props.close} onProcess={([newTitle, newVisibility, newDescription, newColor]) => { const req: HttpTimelinePatchRequest = {}; if (newTitle !== timeline.title) { diff --git a/FrontEnd/src/views/timeline-common/index.css b/FrontEnd/src/views/timeline-common/index.css index e38d0ba7..6929f9ae 100644 --- a/FrontEnd/src/views/timeline-common/index.css +++ b/FrontEnd/src/views/timeline-common/index.css @@ -13,19 +13,19 @@ @keyframes timeline-line-node-noncurrent { to { - box-shadow: 0 0 20px 3px var(--tl-primary-lighter-color); + box-shadow: 0 0 20px 3px var(--cru-primary-l1-color); } } @keyframes timeline-line-node-current { to { - box-shadow: 0 0 20px 3px var(--tl-primary-enhance-lighter-color); + box-shadow: 0 0 20px 3px var(--cru-primary-enhance-l1-color); } } @keyframes timeline-line-node-loading { to { - box-shadow: 0 0 20px 3px var(--tl-primary-lighter-color); + box-shadow: 0 0 20px 3px var(--cru-primary-l1-color); } } @@ -79,7 +79,7 @@ .timeline-line .segment { width: 7px; - background: var(--tl-primary-color); + background: var(--cru-primary-color); } .timeline-line .segment.start { height: 1.8em; @@ -91,7 +91,7 @@ .timeline-line .segment.current-end { height: 2em; flex: 0 0 auto; - background: linear-gradient(var(--tl-primary-enhance-color), white); + background: linear-gradient(var(--cru-primary-enhance-color), white); } .timeline-line .node-container { flex: 0 0 auto; @@ -103,7 +103,7 @@ width: 20px; height: 20px; position: absolute; - background: var(--tl-primary-color); + background: var(--cru-primary-color); left: -1px; top: -1px; border-radius: 50%; @@ -113,7 +113,7 @@ animation-name: timeline-line-node-noncurrent; } .timeline-line .node-loading-edge { - color: var(--tl-primary-color); + color: var(--cru-primary-color); width: 38px; height: 38px; position: absolute; @@ -125,22 +125,22 @@ } .timeline-line.current .segment.start { background: linear-gradient( - var(--tl-primary-color), - var(--tl-primary-enhance-color) + var(--cru-primary-color), + var(--cru-primary-enhance-color) ); } .timeline-line.current .segment.end { - background: var(--tl-primary-enhance-color); + background: var(--cru-primary-enhance-color); } .timeline-line.current .node { - background: var(--tl-primary-enhance-color); + background: var(--cru-primary-enhance-color); animation-name: timeline-line-node-current; } .timeline-line.loading .node { - background: var(--tl-primary-color); + background: var(--cru-primary-color); animation-name: timeline-line-node-loading; } @@ -239,6 +239,7 @@ .timeline-template-card { position: fixed; + z-index: 1029; top: 56px; right: 0; margin: 0.5em; diff --git a/FrontEnd/src/views/timeline/TimelineCard.tsx b/FrontEnd/src/views/timeline/TimelineCard.tsx index 86063843..56057560 100644 --- a/FrontEnd/src/views/timeline/TimelineCard.tsx +++ b/FrontEnd/src/views/timeline/TimelineCard.tsx @@ -18,18 +18,20 @@ const TimelineCard: React.FC<TimelinePageCardProps> = (props) => { <TimelinePageCardTemplate infoArea={ <> - <h3 className="tl-color-primary d-inline-block align-middle"> + <h3 className="cru-color-primary d-inline-block align-middle"> {timeline.title} - <small className="ms-3 text-secondary">{timeline.name}</small> + <small className="ms-3 cru-color-secondary"> + {timeline.name} + </small> </h3> - <div className="align-middle"> + <div> <UserAvatar username={timeline.owner.username} - className="avatar small rounded-circle me-3" + className="cru-avatar small cru-round me-3" /> {timeline.owner.nickname} - <small className="ms-3 text-secondary"> - src{timeline.owner.username} + <small className="ms-3 cru-color-secondary"> + @{timeline.owner.username} </small> </div> </> diff --git a/FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx b/FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx index dbca62ca..68dedf86 100644 --- a/FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx +++ b/FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx @@ -4,7 +4,7 @@ import { Trans } from "react-i18next"; import { getHttpTimelineClient, HttpTimelineInfo } from "@/http/timeline"; -import OperationDialog from "../common/OperationDialog"; +import OperationDialog from "../common/dailog/OperationDialog"; interface TimelineDeleteDialog { timeline: HttpTimelineInfo; @@ -20,13 +20,13 @@ const TimelineDeleteDialog: React.FC<TimelineDeleteDialog> = (props) => { return ( <OperationDialog open={props.open} - close={props.close} + onClose={props.close} title="timeline.deleteDialog.title" themeColor="danger" inputPrompt={() => { return ( <Trans i18nKey="timeline.deleteDialog.inputPrompt"> - 0<code className="mx-2">{{ name }}</code>2 + 0<code className="mx-2">{{ name: timeline.name }}</code>2 </Trans> ); }} diff --git a/FrontEnd/src/views/user/UserCard.tsx b/FrontEnd/src/views/user/UserCard.tsx index e7e4252e..739d26ee 100644 --- a/FrontEnd/src/views/user/UserCard.tsx +++ b/FrontEnd/src/views/user/UserCard.tsx @@ -16,14 +16,16 @@ const UserCard: React.FC<TimelinePageCardProps> = (props) => { <TimelinePageCardTemplate infoArea={ <> - <h3 className="tl-color-primary d-inline-block align-middle"> + <h3 className="cru-color-primary d-inline-block"> {timeline.title} - <small className="ms-3 text-secondary">{timeline.name}</small> + <small className="ms-3 cru-color-secondary"> + {timeline.name} + </small> </h3> - <div className="align-middle"> + <div> <UserAvatar username={timeline.owner.username} - className="avatar small rounded-circle me-3" + className="cru-avatar small cru-round me-3" /> {timeline.owner.nickname} </div> diff --git a/FrontEnd/src/views/user/index.css b/FrontEnd/src/views/user/index.css index 35f01d38..e69de29b 100644 --- a/FrontEnd/src/views/user/index.css +++ b/FrontEnd/src/views/user/index.css @@ -1,9 +0,0 @@ -.change-avatar-cropper-row {
- max-height: 400px;
-}
-
-.change-avatar-img {
- min-width: 50%;
- max-width: 100%;
- max-height: 400px;
-}
|