diff options
Diffstat (limited to 'FrontEnd/src/views/common')
41 files changed, 1219 insertions, 625 deletions
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> + ); +} |