diff options
Diffstat (limited to 'FrontEnd/src/views/common')
21 files changed, 1764 insertions, 0 deletions
diff --git a/FrontEnd/src/views/common/AppBar.tsx b/FrontEnd/src/views/common/AppBar.tsx new file mode 100644 index 00000000..91dfbee9 --- /dev/null +++ b/FrontEnd/src/views/common/AppBar.tsx @@ -0,0 +1,80 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { Link, NavLink } from "react-router-dom"; +import classnames from "classnames"; +import { useMediaQuery } from "react-responsive"; + +import { useUser } from "@/services/user"; + +import TimelineLogo from "./TimelineLogo"; +import UserAvatar from "./user/UserAvatar"; + +const AppBar: React.FC = (_) => { + const { t } = useTranslation(); + + const user = useUser(); + const hasAdministrationPermission = user && user.hasAdministrationPermission; + + const isSmallScreen = useMediaQuery({ maxWidth: 576 }); + + const [expand, setExpand] = React.useState<boolean>(false); + const collapse = (): void => setExpand(false); + const toggleExpand = (): void => setExpand(!expand); + + const createLink = ( + link: string, + label: React.ReactNode, + className?: string + ): React.ReactNode => ( + <NavLink + to={link} + activeClassName="active" + onClick={collapse} + className={className} + > + {label} + </NavLink> + ); + + return ( + <nav className={classnames("app-bar", isSmallScreen && "small-screen")}> + <Link to="/" className="app-bar-brand active"> + <TimelineLogo className="app-bar-brand-icon" /> + Timeline + </Link> + + {isSmallScreen && ( + <i className="bi-list app-bar-toggler" onClick={toggleExpand} /> + )} + + <div + className={classnames( + "app-bar-main-area", + !expand && "app-bar-collapse" + )} + > + <div className="app-bar-link-area"> + {createLink("/settings", t("nav.settings"))} + {createLink("/about", t("nav.about"))} + {hasAdministrationPermission && + createLink("/admin", t("nav.administration"))} + </div> + + <div className="app-bar-user-area"> + {user != null + ? createLink( + "/", + <UserAvatar + username={user.username} + className="avatar small rounded-circle bg-white cursor-pointer ml-auto" + />, + "app-bar-avatar" + ) + : createLink("/login", t("nav.login"))} + </div> + </div> + </nav> + ); +}; + +export default AppBar; diff --git a/FrontEnd/src/views/common/BlobImage.tsx b/FrontEnd/src/views/common/BlobImage.tsx new file mode 100644 index 00000000..0dd25c52 --- /dev/null +++ b/FrontEnd/src/views/common/BlobImage.tsx @@ -0,0 +1,27 @@ +import React from "react"; + +const BlobImage: React.FC< + Omit<React.ImgHTMLAttributes<HTMLImageElement>, "src"> & { + blob?: Blob | unknown; + } +> = (props) => { + const { blob, ...otherProps } = props; + + const [url, setUrl] = React.useState<string | undefined>(undefined); + + React.useEffect(() => { + if (blob instanceof Blob) { + const url = URL.createObjectURL(blob); + setUrl(url); + return () => { + URL.revokeObjectURL(url); + }; + } else { + setUrl(undefined); + } + }, [blob]); + + return <img {...otherProps} src={url} />; +}; + +export default BlobImage; diff --git a/FrontEnd/src/views/common/ConfirmDialog.tsx b/FrontEnd/src/views/common/ConfirmDialog.tsx new file mode 100644 index 00000000..72940c51 --- /dev/null +++ b/FrontEnd/src/views/common/ConfirmDialog.tsx @@ -0,0 +1,40 @@ +import { convertI18nText, I18nText } from "@/common"; +import React from "react"; +import { Modal, Button } from "react-bootstrap"; +import { useTranslation } from "react-i18next"; + +const ConfirmDialog: React.FC<{ + onClose: () => void; + onConfirm: () => void; + title: I18nText; + body: I18nText; +}> = ({ onClose, onConfirm, title, body }) => { + const { t } = useTranslation(); + + return ( + <Modal onHide={onClose} show centered> + <Modal.Header> + <Modal.Title className="text-danger"> + {convertI18nText(title, t)} + </Modal.Title> + </Modal.Header> + <Modal.Body>{convertI18nText(body, t)}</Modal.Body> + <Modal.Footer> + <Button variant="secondary" onClick={onClose}> + {t("operationDialog.cancel")} + </Button> + <Button + variant="danger" + onClick={() => { + onConfirm(); + onClose(); + }} + > + {t("operationDialog.confirm")} + </Button> + </Modal.Footer> + </Modal> + ); +}; + +export default ConfirmDialog; diff --git a/FrontEnd/src/views/common/FlatButton.tsx b/FrontEnd/src/views/common/FlatButton.tsx new file mode 100644 index 00000000..b1f7a051 --- /dev/null +++ b/FrontEnd/src/views/common/FlatButton.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import classnames from "classnames"; + +import { BootstrapThemeColor } from "@/common"; + +export interface FlatButtonProps { + variant?: BootstrapThemeColor | string; + disabled?: boolean; + className?: string; + style?: React.CSSProperties; + onClick?: () => void; +} + +const FlatButton: React.FC<FlatButtonProps> = (props) => { + const { disabled, className, style } = props; + const variant = props.variant ?? "primary"; + + const onClick = disabled ? undefined : props.onClick; + + return ( + <div + className={classnames( + "flat-button", + variant, + disabled ? "disabled" : null, + className + )} + style={style} + onClick={onClick} + > + {props.children} + </div> + ); +}; + +export default FlatButton; diff --git a/FrontEnd/src/views/common/FullPage.tsx b/FrontEnd/src/views/common/FullPage.tsx new file mode 100644 index 00000000..1b59045a --- /dev/null +++ b/FrontEnd/src/views/common/FullPage.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import classnames from "classnames"; + +export interface FullPageProps { + show: boolean; + onBack: () => void; + contentContainerClassName?: string; +} + +const FullPage: React.FC<FullPageProps> = ({ + show, + onBack, + children, + contentContainerClassName, +}) => { + return ( + <div + className="cru-full-page" + style={{ display: show ? undefined : "none" }} + > + <div className="cru-full-page-top-bar"> + <i + className="icon-button bi-arrow-left text-white ms-3" + onClick={onBack} + /> + </div> + <div + className={classnames( + "cru-full-page-content-container", + contentContainerClassName + )} + > + {children} + </div> + </div> + ); +}; + +export default FullPage; diff --git a/FrontEnd/src/views/common/ImageCropper.tsx b/FrontEnd/src/views/common/ImageCropper.tsx new file mode 100644 index 00000000..2ef5b7ed --- /dev/null +++ b/FrontEnd/src/views/common/ImageCropper.tsx @@ -0,0 +1,306 @@ +import React from "react"; +import classnames from "classnames"; + +import { UiLogicError } from "@/common"; + +export interface Clip { + left: number; + top: number; + width: number; +} + +interface NormailizedClip extends Clip { + height: number; +} + +interface ImageInfo { + width: number; + height: number; + landscape: boolean; + ratio: number; + maxClipWidth: number; + maxClipHeight: number; +} + +interface ImageCropperSavedState { + clip: NormailizedClip; + x: number; + y: number; + pointerId: number; +} + +export interface ImageCropperProps { + clip: Clip | null; + imageUrl: string; + onChange: (clip: Clip) => void; + imageElementCallback?: (element: HTMLImageElement | null) => void; + className?: string; +} + +const ImageCropper = (props: ImageCropperProps): React.ReactElement => { + const { clip, imageUrl, onChange, imageElementCallback, className } = props; + + const [oldState, setOldState] = React.useState<ImageCropperSavedState | null>( + null + ); + const [imageInfo, setImageInfo] = React.useState<ImageInfo | null>(null); + + const normalizeClip = (c: Clip | null | undefined): NormailizedClip => { + if (c == null) { + return { left: 0, top: 0, width: 0, height: 0 }; + } + + return { + left: c.left || 0, + top: c.top || 0, + width: c.width || 0, + height: imageInfo != null ? (c.width || 0) / imageInfo.ratio : 0, + }; + }; + + const c = normalizeClip(clip); + + const imgElementRef = React.useRef<HTMLImageElement | null>(null); + + const onImageRef = React.useCallback( + (e: HTMLImageElement | null) => { + imgElementRef.current = e; + if (imageElementCallback != null && e == null) { + imageElementCallback(null); + } + }, + [imageElementCallback] + ); + + const onImageLoad = React.useCallback( + (e: React.SyntheticEvent<HTMLImageElement>) => { + const img = e.currentTarget; + const landscape = img.naturalWidth >= img.naturalHeight; + + const info = { + width: img.naturalWidth, + height: img.naturalHeight, + landscape, + ratio: img.naturalHeight / img.naturalWidth, + maxClipWidth: landscape ? img.naturalHeight / img.naturalWidth : 1, + maxClipHeight: landscape ? 1 : img.naturalWidth / img.naturalHeight, + }; + setImageInfo(info); + onChange({ left: 0, top: 0, width: info.maxClipWidth }); + if (imageElementCallback != null) { + imageElementCallback(img); + } + }, + [onChange, imageElementCallback] + ); + + const onPointerDown = React.useCallback( + (e: React.PointerEvent) => { + if (oldState != null) return; + e.currentTarget.setPointerCapture(e.pointerId); + setOldState({ + x: e.clientX, + y: e.clientY, + clip: c, + pointerId: e.pointerId, + }); + }, + [oldState, c] + ); + + const onPointerUp = React.useCallback( + (e: React.PointerEvent) => { + if (oldState == null || oldState.pointerId !== e.pointerId) return; + e.currentTarget.releasePointerCapture(e.pointerId); + setOldState(null); + }, + [oldState] + ); + + const onPointerMove = React.useCallback( + (e: React.PointerEvent) => { + if (oldState == null) return; + + const oldClip = oldState.clip; + + const movement = { x: e.clientX - oldState.x, y: e.clientY - oldState.y }; + + const { current: imgElement } = imgElementRef; + + if (imgElement == null) throw new UiLogicError("Image element is null."); + + const moveRatio = { + x: movement.x / imgElement.width, + y: movement.y / imgElement.height, + }; + + const newRatio = { + x: oldClip.left + moveRatio.x, + y: oldClip.top + moveRatio.y, + }; + if (newRatio.x < 0) { + newRatio.x = 0; + } else if (newRatio.x > 1 - oldClip.width) { + newRatio.x = 1 - oldClip.width; + } + if (newRatio.y < 0) { + newRatio.y = 0; + } else if (newRatio.y > 1 - oldClip.height) { + newRatio.y = 1 - oldClip.height; + } + + onChange({ left: newRatio.x, top: newRatio.y, width: oldClip.width }); + }, + [oldState, onChange] + ); + + const onHandlerPointerMove = React.useCallback( + (e: React.PointerEvent) => { + if (oldState == null) return; + + const oldClip = oldState.clip; + + const movement = { x: e.clientX - oldState.x, y: e.clientY - oldState.y }; + + const ratio = imageInfo == null ? 1 : imageInfo.ratio; + + const { current: imgElement } = imgElementRef; + + if (imgElement == null) throw new UiLogicError("Image element is null."); + + const moveRatio = { + x: movement.x / imgElement.width, + y: movement.x / imgElement.width / ratio, + }; + + const newRatio = { + x: oldClip.width + moveRatio.x, + y: oldClip.height + moveRatio.y, + }; + + const maxRatio = { + x: Math.min(1 - oldClip.left, newRatio.x), + y: Math.min(1 - oldClip.top, newRatio.y), + }; + + const maxWidthRatio = Math.min(maxRatio.x, maxRatio.y * ratio); + + let newWidth; + if (newRatio.x < 0) { + newWidth = 0; + } else if (newRatio.x > maxWidthRatio) { + newWidth = maxWidthRatio; + } else { + newWidth = newRatio.x; + } + + onChange({ left: oldClip.left, top: oldClip.top, width: newWidth }); + }, + [imageInfo, oldState, onChange] + ); + + const toPercentage = (n: number): string => `${n}%`; + + // fuck!!! I just can't find a better way to implement this in pure css + const containerStyle: React.CSSProperties = (() => { + if (imageInfo == null) { + return { width: "100%", paddingTop: "100%", height: 0 }; + } else { + if (imageInfo.ratio > 1) { + return { + width: toPercentage(100 / imageInfo.ratio), + paddingTop: "100%", + height: 0, + }; + } else { + return { + width: "100%", + paddingTop: toPercentage(100 * imageInfo.ratio), + height: 0, + }; + } + } + })(); + + return ( + <div + className={classnames("image-cropper-container", className)} + style={containerStyle} + > + <img ref={onImageRef} src={imageUrl} onLoad={onImageLoad} alt="to crop" /> + <div className="image-cropper-mask-container"> + <div + className="image-cropper-mask" + touch-action="none" + style={{ + left: toPercentage(c.left * 100), + top: toPercentage(c.top * 100), + width: toPercentage(c.width * 100), + height: toPercentage(c.height * 100), + }} + onPointerMove={onPointerMove} + onPointerDown={onPointerDown} + onPointerUp={onPointerUp} + /> + </div> + <div + className="image-cropper-handler" + touch-action="none" + style={{ + left: `calc(${(c.left + c.width) * 100}% - 15px)`, + top: `calc(${(c.top + c.height) * 100}% - 15px)`, + }} + onPointerMove={onHandlerPointerMove} + onPointerDown={onPointerDown} + onPointerUp={onPointerUp} + /> + </div> + ); +}; + +export default ImageCropper; + +export function applyClipToImage( + image: HTMLImageElement, + clip: Clip, + mimeType: string +): Promise<Blob> { + return new Promise((resolve, reject) => { + const naturalSize = { + width: image.naturalWidth, + height: image.naturalHeight, + }; + const clipArea = { + x: naturalSize.width * clip.left, + y: naturalSize.height * clip.top, + length: naturalSize.width * clip.width, + }; + + const canvas = document.createElement("canvas"); + canvas.width = clipArea.length; + canvas.height = clipArea.length; + const context = canvas.getContext("2d"); + + if (context == null) throw new Error("Failed to create context."); + + context.drawImage( + image, + clipArea.x, + clipArea.y, + clipArea.length, + clipArea.length, + 0, + 0, + clipArea.length, + clipArea.length + ); + + canvas.toBlob((blob) => { + if (blob == null) { + reject(new Error("canvas.toBlob returns null")); + } else { + resolve(blob); + } + }, mimeType); + }); +} diff --git a/FrontEnd/src/views/common/LoadFailReload.tsx b/FrontEnd/src/views/common/LoadFailReload.tsx new file mode 100644 index 00000000..a80e7b76 --- /dev/null +++ b/FrontEnd/src/views/common/LoadFailReload.tsx @@ -0,0 +1,37 @@ +import React from "react"; +import { Trans } from "react-i18next"; + +export interface LoadFailReloadProps { + className?: string; + style?: React.CSSProperties; + onReload: () => void; +} + +const LoadFailReload: React.FC<LoadFailReloadProps> = ({ + onReload, + className, + style, +}) => { + return ( + <Trans + i18nKey="loadFailReload" + parent="div" + className={className} + style={style} + > + 0 + <a + href="#" + onClick={(e) => { + onReload(); + e.preventDefault(); + }} + > + 1 + </a> + 2 + </Trans> + ); +}; + +export default LoadFailReload; diff --git a/FrontEnd/src/views/common/LoadingButton.tsx b/FrontEnd/src/views/common/LoadingButton.tsx new file mode 100644 index 00000000..cd9f1adc --- /dev/null +++ b/FrontEnd/src/views/common/LoadingButton.tsx @@ -0,0 +1,29 @@ +import React from "react"; +import { Button, ButtonProps, Spinner } from "react-bootstrap"; + +const LoadingButton: React.FC<{ loading?: boolean } & ButtonProps> = ({ + loading, + variant, + disabled, + ...otherProps +}) => { + return ( + <Button + variant={variant != null ? `outline-${variant}` : "outline-primary"} + disabled={disabled || loading} + {...otherProps} + > + {otherProps.children} + {loading ? ( + <Spinner + className="ms-1" + variant={variant} + animation="grow" + size="sm" + /> + ) : null} + </Button> + ); +}; + +export default LoadingButton; diff --git a/FrontEnd/src/views/common/LoadingPage.tsx b/FrontEnd/src/views/common/LoadingPage.tsx new file mode 100644 index 00000000..590fafa0 --- /dev/null +++ b/FrontEnd/src/views/common/LoadingPage.tsx @@ -0,0 +1,12 @@ +import React from "react"; +import { Spinner } from "react-bootstrap"; + +const LoadingPage: React.FC = () => { + return ( + <div className="position-fixed w-100 h-100 d-flex justify-content-center align-items-center"> + <Spinner variant="primary" animation="border" /> + </div> + ); +}; + +export default LoadingPage; diff --git a/FrontEnd/src/views/common/Menu.tsx b/FrontEnd/src/views/common/Menu.tsx new file mode 100644 index 00000000..ae73a331 --- /dev/null +++ b/FrontEnd/src/views/common/Menu.tsx @@ -0,0 +1,92 @@ +import React from "react"; +import classnames from "classnames"; +import { OverlayTrigger, OverlayTriggerProps, Popover } from "react-bootstrap"; +import { useTranslation } from "react-i18next"; + +import { BootstrapThemeColor, convertI18nText, I18nText } from "@/common"; + +export type MenuItem = + | { + type: "divider"; + } + | { + type: "button"; + text: I18nText; + iconClassName?: string; + color?: BootstrapThemeColor; + onClick: () => void; + }; + +export type MenuItems = MenuItem[]; + +export interface MenuProps { + items: MenuItems; + className?: string; + onItemClicked?: () => void; +} + +const Menu: React.FC<MenuProps> = ({ items, className, onItemClicked }) => { + const { t } = useTranslation(); + + return ( + <div className={classnames("cru-menu", className)}> + {items.map((item, index) => { + if (item.type === "divider") { + return <div key={index} className="cru-menu-divider" />; + } else { + return ( + <div + key={index} + className={classnames( + "cru-menu-item", + `color-${item.color ?? "primary"}` + )} + onClick={() => { + item.onClick(); + onItemClicked?.(); + }} + > + {item.iconClassName != null ? ( + <i + className={classnames( + item.iconClassName, + "cru-menu-item-icon" + )} + /> + ) : null} + {convertI18nText(item.text, t)} + </div> + ); + } + })} + </div> + ); +}; + +export default Menu; + +export interface PopupMenuProps { + items: MenuItems; + children: OverlayTriggerProps["children"]; +} + +export const PopupMenu: React.FC<PopupMenuProps> = ({ items, children }) => { + const [show, setShow] = React.useState<boolean>(false); + const toggle = (): void => setShow(!show); + + return ( + <OverlayTrigger + trigger="click" + rootClose + overlay={ + <Popover id="menu-popover"> + <Menu items={items} onItemClicked={() => setShow(false)} /> + </Popover> + } + show={show} + onToggle={toggle} + > + {children} + </OverlayTrigger> + ); +}; diff --git a/FrontEnd/src/views/common/OperationDialog.tsx b/FrontEnd/src/views/common/OperationDialog.tsx new file mode 100644 index 00000000..ac4c51b9 --- /dev/null +++ b/FrontEnd/src/views/common/OperationDialog.tsx @@ -0,0 +1,471 @@ +import React, { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Form, Button, Modal } from "react-bootstrap"; +import { TwitterPicker } from "react-color"; +import moment from "moment"; + +import { convertI18nText, I18nText, UiLogicError } from "@/common"; + +import LoadingButton from "./LoadingButton"; + +interface DefaultErrorPromptProps { + error?: string; +} + +const DefaultErrorPrompt: React.FC<DefaultErrorPromptProps> = (props) => { + const { t } = useTranslation(); + + let result = <p className="text-danger">{t("operationDialog.error")}</p>; + + if (props.error != null) { + result = ( + <> + {result} + <p className="text-danger">{props.error}</p> + </> + ); + } + + return result; +}; + +export interface OperationDialogTextInput { + type: "text"; + label?: I18nText; + password?: boolean; + initValue?: string; + textFieldProps?: Omit< + React.InputHTMLAttributes<HTMLInputElement>, + "type" | "value" | "onChange" | "aria-relevant" + >; + helperText?: string; +} + +export interface OperationDialogBoolInput { + type: "bool"; + label: I18nText; + initValue?: boolean; +} + +export interface OperationDialogSelectInputOption { + value: string; + label: I18nText; + icon?: React.ReactElement; +} + +export interface OperationDialogSelectInput { + type: "select"; + label: I18nText; + options: OperationDialogSelectInputOption[]; + initValue?: string; +} + +export interface OperationDialogColorInput { + type: "color"; + label?: I18nText; + initValue?: string | null; + canBeNull?: boolean; +} + +export interface OperationDialogDateTimeInput { + type: "datetime"; + label?: I18nText; + initValue?: string; +} + +export type OperationDialogInput = + | OperationDialogTextInput + | OperationDialogBoolInput + | OperationDialogSelectInput + | OperationDialogColorInput + | OperationDialogDateTimeInput; + +interface OperationInputTypeStringToValueTypeMap { + text: string; + bool: boolean; + select: string; + color: string | null; + datetime: string; +} + +type MapOperationInputTypeStringToValueType<Type> = + Type extends keyof OperationInputTypeStringToValueTypeMap + ? OperationInputTypeStringToValueTypeMap[Type] + : never; + +type MapOperationInputInfoValueType<T> = T extends OperationDialogInput + ? MapOperationInputTypeStringToValueType<T["type"]> + : T; + +const initValueMapperMap: { + [T in OperationDialogInput as T["type"]]: ( + item: T + ) => MapOperationInputInfoValueType<T>; +} = { + bool: (item) => item.initValue ?? false, + color: (item) => item.initValue ?? null, + datetime: (item) => { + if (item.initValue != null) { + return moment(item.initValue).format("YYYY-MM-DDTHH:mm:ss"); + } else { + return ""; + } + }, + select: (item) => item.initValue ?? item.options[0].value, + text: (item) => item.initValue ?? "", +}; + +type MapOperationInputInfoValueTypeList< + Tuple extends readonly OperationDialogInput[] +> = { + [Index in keyof Tuple]: MapOperationInputInfoValueType<Tuple[Index]>; +} & { length: Tuple["length"] }; + +export type OperationInputError = + | { + [index: number]: I18nText | null | undefined; + } + | null + | undefined; + +const isNoError = (error: OperationInputError): boolean => { + if (error == null) return true; + for (const key in error) { + if (error[key] != null) return false; + } + return true; +}; + +export interface OperationDialogProps< + TData, + OperationInputInfoList extends readonly OperationDialogInput[] +> { + open: boolean; + close: () => void; + title: I18nText | (() => React.ReactNode); + themeColor?: "danger" | "success" | string; + onProcess: ( + inputs: MapOperationInputInfoValueTypeList<OperationInputInfoList> + ) => Promise<TData>; + inputScheme?: OperationInputInfoList; + inputValidator?: ( + inputs: MapOperationInputInfoValueTypeList<OperationInputInfoList> + ) => OperationInputError; + inputPrompt?: I18nText | (() => React.ReactNode); + processPrompt?: () => React.ReactNode; + successPrompt?: (data: TData) => React.ReactNode; + failurePrompt?: (error: unknown) => React.ReactNode; + onSuccessAndClose?: (data: TData) => void; +} + +const OperationDialog = < + TData, + OperationInputInfoList extends readonly OperationDialogInput[] +>( + props: OperationDialogProps<TData, OperationInputInfoList> +): React.ReactElement => { + const inputScheme = (props.inputScheme ?? + []) as readonly OperationDialogInput[]; + + const { t } = useTranslation(); + + type Step = + | { type: "input" } + | { type: "process" } + | { + type: "success"; + data: TData; + } + | { + type: "failure"; + data: unknown; + }; + const [step, setStep] = useState<Step>({ type: "input" }); + + type ValueType = boolean | string | null | undefined; + + const [values, setValues] = useState<ValueType[]>( + inputScheme.map((item) => { + if (item.type in initValueMapperMap) { + return ( + initValueMapperMap[item.type] as ( + i: OperationDialogInput + ) => ValueType + )(item); + } else { + throw new UiLogicError("Unknown input scheme."); + } + }) + ); + const [dirtyList, setDirtyList] = useState<boolean[]>(() => + inputScheme.map(() => false) + ); + const [inputError, setInputError] = useState<OperationInputError>(); + + const close = (): void => { + if (step.type !== "process") { + props.close(); + if (step.type === "success" && props.onSuccessAndClose) { + props.onSuccessAndClose(step.data); + } + } else { + console.log("Attempt to close modal when processing."); + } + }; + + const onConfirm = (): void => { + setStep({ type: "process" }); + props + .onProcess( + values.map((v, index) => { + if (inputScheme[index].type === "datetime" && v !== "") + return new Date(v as string).toISOString(); + else return v; + }) as unknown as MapOperationInputInfoValueTypeList<OperationInputInfoList> + ) + .then( + (d) => { + setStep({ + type: "success", + data: d, + }); + }, + (e: unknown) => { + setStep({ + type: "failure", + data: e, + }); + } + ); + }; + + let body: React.ReactNode; + if (step.type === "input" || step.type === "process") { + const process = step.type === "process"; + + let inputPrompt = + typeof props.inputPrompt === "function" + ? props.inputPrompt() + : convertI18nText(props.inputPrompt, t); + inputPrompt = <h6>{inputPrompt}</h6>; + + const validate = (values: ValueType[]): boolean => { + const { inputValidator } = props; + if (inputValidator != null) { + const result = inputValidator( + values as unknown as MapOperationInputInfoValueTypeList<OperationInputInfoList> + ); + setInputError(result); + return isNoError(result); + } + return true; + }; + + const updateValue = (index: number, newValue: ValueType): void => { + const oldValues = values; + const newValues = oldValues.slice(); + newValues[index] = newValue; + setValues(newValues); + if (dirtyList[index] === false) { + const newDirtyList = dirtyList.slice(); + newDirtyList[index] = true; + setDirtyList(newDirtyList); + } + validate(newValues); + }; + + const canProcess = isNoError(inputError); + + body = ( + <> + <Modal.Body> + {inputPrompt} + {inputScheme.map((item, index) => { + const value = values[index]; + const error: string | null = + dirtyList[index] && inputError != null + ? convertI18nText(inputError[index], t) + : null; + + if (item.type === "text") { + return ( + <Form.Group key={index}> + {item.label && ( + <Form.Label>{convertI18nText(item.label, t)}</Form.Label> + )} + <Form.Control + type={item.password === true ? "password" : "text"} + value={value as string} + onChange={(e) => { + const v = e.target.value; + updateValue(index, v); + }} + isInvalid={error != null} + disabled={process} + /> + {error != null && ( + <Form.Control.Feedback type="invalid"> + {error} + </Form.Control.Feedback> + )} + {item.helperText && ( + <Form.Text>{t(item.helperText)}</Form.Text> + )} + </Form.Group> + ); + } else if (item.type === "bool") { + return ( + <Form.Group key={index}> + <Form.Check<"input"> + type="checkbox" + checked={value as boolean} + onChange={(event) => { + updateValue(index, event.currentTarget.checked); + }} + label={convertI18nText(item.label, t)} + disabled={process} + /> + </Form.Group> + ); + } else if (item.type === "select") { + return ( + <Form.Group key={index}> + <Form.Label>{convertI18nText(item.label, t)}</Form.Label> + <Form.Control + as="select" + value={value as string} + onChange={(event) => { + updateValue(index, event.target.value); + }} + disabled={process} + > + {item.options.map((option, i) => { + return ( + <option value={option.value} key={i}> + {option.icon} + {convertI18nText(option.label, t)} + </option> + ); + })} + </Form.Control> + </Form.Group> + ); + } else if (item.type === "color") { + return ( + <Form.Group key={index}> + {item.canBeNull ? ( + <Form.Check<"input"> + type="checkbox" + checked={value !== null} + onChange={(event) => { + if (event.currentTarget.checked) { + updateValue(index, "#007bff"); + } else { + updateValue(index, null); + } + }} + label={convertI18nText(item.label, t)} + disabled={process} + /> + ) : ( + <Form.Label>{convertI18nText(item.label, t)}</Form.Label> + )} + {value !== null && ( + <TwitterPicker + color={value as string} + onChange={(result) => updateValue(index, result.hex)} + /> + )} + </Form.Group> + ); + } else if (item.type === "datetime") { + return ( + <Form.Group key={index}> + {item.label && ( + <Form.Label>{convertI18nText(item.label, t)}</Form.Label> + )} + <Form.Control + type="datetime-local" + value={value as string} + onChange={(e) => { + const v = e.target.value; + updateValue(index, v); + }} + isInvalid={error != null} + disabled={process} + /> + {error != null && ( + <Form.Control.Feedback type="invalid"> + {error} + </Form.Control.Feedback> + )} + </Form.Group> + ); + } + })} + </Modal.Body> + <Modal.Footer> + <Button variant="outline-secondary" onClick={close}> + {t("operationDialog.cancel")} + </Button> + <LoadingButton + variant={props.themeColor} + loading={process} + disabled={!canProcess} + onClick={() => { + setDirtyList(inputScheme.map(() => true)); + if (validate(values)) { + onConfirm(); + } + }} + > + {t("operationDialog.confirm")} + </LoadingButton> + </Modal.Footer> + </> + ); + } else { + let content: React.ReactNode; + const result = step; + if (result.type === "success") { + content = + props.successPrompt?.(result.data) ?? t("operationDialog.success"); + if (typeof content === "string") + content = <p className="text-success">{content}</p>; + } else { + content = props.failurePrompt?.(result.data) ?? <DefaultErrorPrompt />; + if (typeof content === "string") + content = <DefaultErrorPrompt error={content} />; + } + body = ( + <> + <Modal.Body>{content}</Modal.Body> + <Modal.Footer> + <Button variant="primary" onClick={close}> + {t("operationDialog.ok")} + </Button> + </Modal.Footer> + </> + ); + } + + const title = + typeof props.title === "function" + ? props.title() + : convertI18nText(props.title, t); + + return ( + <Modal show={props.open} onHide={close}> + <Modal.Header + className={ + props.themeColor != null ? "text-" + props.themeColor : undefined + } + > + {title} + </Modal.Header> + {body} + </Modal> + ); +}; + +export default OperationDialog; diff --git a/FrontEnd/src/views/common/SearchInput.tsx b/FrontEnd/src/views/common/SearchInput.tsx new file mode 100644 index 00000000..ccb6dad6 --- /dev/null +++ b/FrontEnd/src/views/common/SearchInput.tsx @@ -0,0 +1,78 @@ +import React, { useCallback } from "react"; +import classnames from "classnames"; +import { useTranslation } from "react-i18next"; +import { Spinner, Form, Button } from "react-bootstrap"; + +export interface SearchInputProps { + value: string; + onChange: (value: string) => void; + onButtonClick: () => void; + className?: string; + loading?: boolean; + buttonText?: string; + placeholder?: string; + additionalButton?: React.ReactNode; + alwaysOneline?: boolean; +} + +const SearchInput: React.FC<SearchInputProps> = (props) => { + const { onChange, onButtonClick, alwaysOneline } = props; + + const { t } = useTranslation(); + + const onInputChange = useCallback( + (event: React.ChangeEvent<HTMLInputElement>): void => { + onChange(event.currentTarget.value); + }, + [onChange] + ); + + const onInputKeyPress = useCallback( + (event: React.KeyboardEvent<HTMLInputElement>): void => { + if (event.key === "Enter") { + onButtonClick(); + event.preventDefault(); + } + }, + [onButtonClick] + ); + + return ( + <Form + className={classnames( + "cru-search-input", + alwaysOneline ? "flex-nowrap" : "flex-sm-nowrap", + props.className + )} + > + <Form.Control + className="me-sm-2 flex-grow-1" + value={props.value} + onChange={onInputChange} + onKeyPress={onInputKeyPress} + placeholder={props.placeholder} + /> + {props.additionalButton ? ( + <div className="mt-2 mt-sm-0 flex-shrink-0 order-sm-last ms-sm-2"> + {props.additionalButton} + </div> + ) : null} + <div + className={classnames( + alwaysOneline ? "mt-0 ms-2" : "mt-2 mt-sm-0 ms-auto ms-sm-0", + "flex-shrink-0" + )} + > + {props.loading ? ( + <Spinner variant="primary" animation="border" /> + ) : ( + <Button variant="outline-primary" onClick={props.onButtonClick}> + {props.buttonText ?? t("search")} + </Button> + )} + </div> + </Form> + ); +}; + +export default SearchInput; diff --git a/FrontEnd/src/views/common/Skeleton.tsx b/FrontEnd/src/views/common/Skeleton.tsx new file mode 100644 index 00000000..14886c71 --- /dev/null +++ b/FrontEnd/src/views/common/Skeleton.tsx @@ -0,0 +1,30 @@ +import React from "react"; +import classnames from "classnames"; +import { range } from "lodash"; + +export interface SkeletonProps { + lineNumber?: number; + className?: string; + style?: React.CSSProperties; +} + +const Skeleton: React.FC<SkeletonProps> = (props) => { + const { lineNumber: lineNumberProps, className, style } = props; + const lineNumber = lineNumberProps ?? 3; + + return ( + <div className={classnames(className, "cru-skeleton")} style={style}> + {range(lineNumber).map((i) => ( + <div + key={i} + className={classnames( + "cru-skeleton-line", + i === lineNumber - 1 && "last" + )} + /> + ))} + </div> + ); +}; + +export default Skeleton; diff --git a/FrontEnd/src/views/common/TabPages.tsx b/FrontEnd/src/views/common/TabPages.tsx new file mode 100644 index 00000000..2b1d91cb --- /dev/null +++ b/FrontEnd/src/views/common/TabPages.tsx @@ -0,0 +1,74 @@ +import React from "react"; +import { Nav } from "react-bootstrap"; +import { useTranslation } from "react-i18next"; + +import { convertI18nText, I18nText, UiLogicError } from "@/common"; + +export interface TabPage { + id: string; + tabText: I18nText; + page: React.ReactNode; +} + +export interface TabPagesProps { + pages: TabPage[]; + actions?: React.ReactNode; + className?: string; + style?: React.CSSProperties; + navClassName?: string; + navStyle?: React.CSSProperties; + pageContainerClassName?: string; + pageContainerStyle?: React.CSSProperties; +} + +const TabPages: React.FC<TabPagesProps> = ({ + pages, + actions, + className, + style, + navClassName, + navStyle, + pageContainerClassName, + pageContainerStyle, +}) => { + if (pages.length === 0) { + throw new UiLogicError("Page list can't be empty."); + } + + const { t } = useTranslation(); + + const [tab, setTab] = React.useState<string>(pages[0].id); + + const currentPage = pages.find((p) => p.id === tab); + + if (currentPage == null) { + throw new UiLogicError("Current tab value is bad."); + } + + return ( + <div className={className} style={style}> + <Nav variant="tabs" className={navClassName} style={navStyle}> + {pages.map((page) => ( + <Nav.Item key={page.id}> + <Nav.Link + active={tab === page.id} + onClick={() => { + setTab(page.id); + }} + > + {convertI18nText(page.tabText, t)} + </Nav.Link> + </Nav.Item> + ))} + {actions != null && ( + <div className="ms-auto cru-tab-pages-action-area">{actions}</div> + )} + </Nav> + <div className={pageContainerClassName} style={pageContainerStyle}> + {currentPage.page} + </div> + </div> + ); +}; + +export default TabPages; diff --git a/FrontEnd/src/views/common/TimelineLogo.tsx b/FrontEnd/src/views/common/TimelineLogo.tsx new file mode 100644 index 00000000..27d188fc --- /dev/null +++ b/FrontEnd/src/views/common/TimelineLogo.tsx @@ -0,0 +1,26 @@ +import React, { SVGAttributes } from "react"; + +export interface TimelineLogoProps extends SVGAttributes<SVGElement> { + color?: string; +} + +const TimelineLogo: React.FC<TimelineLogoProps> = (props) => { + const { color, ...forwardProps } = props; + const coercedColor = color ?? "currentcolor"; + return ( + <svg + className={props.className} + viewBox="0 0 100 100" + fill="none" + strokeWidth="12" + stroke={coercedColor} + {...forwardProps} + > + <line x1="50" y1="0" x2="50" y2="25" /> + <circle cx="50" cy="50" r="22" /> + <line x1="50" y1="75" x2="50" y2="100" /> + </svg> + ); +}; + +export default TimelineLogo; diff --git a/FrontEnd/src/views/common/ToggleIconButton.tsx b/FrontEnd/src/views/common/ToggleIconButton.tsx new file mode 100644 index 00000000..c4d2d132 --- /dev/null +++ b/FrontEnd/src/views/common/ToggleIconButton.tsx @@ -0,0 +1,30 @@ +import React from "react"; +import classnames from "classnames"; + +export interface ToggleIconButtonProps + extends React.HTMLAttributes<HTMLElement> { + state: boolean; + trueIconClassName: string; + falseIconClassName: string; +} + +const ToggleIconButton: React.FC<ToggleIconButtonProps> = ({ + state, + className, + trueIconClassName, + falseIconClassName, + ...otherProps +}) => { + return ( + <i + className={classnames( + state ? trueIconClassName : falseIconClassName, + "icon-button", + className + )} + {...otherProps} + /> + ); +}; + +export default ToggleIconButton; diff --git a/FrontEnd/src/views/common/UserTimelineLogo.tsx b/FrontEnd/src/views/common/UserTimelineLogo.tsx new file mode 100644 index 00000000..19b9fee5 --- /dev/null +++ b/FrontEnd/src/views/common/UserTimelineLogo.tsx @@ -0,0 +1,26 @@ +import React, { SVGAttributes } from "react"; + +export interface UserTimelineLogoProps extends SVGAttributes<SVGElement> { + color?: string; +} + +const UserTimelineLogo: React.FC<UserTimelineLogoProps> = (props) => { + const { color, ...forwardProps } = props; + const coercedColor = color ?? "currentcolor"; + + return ( + <svg viewBox="0 0 100 100" {...forwardProps}> + <g fill="none" stroke={coercedColor} strokeWidth="12"> + <line x1="50" x2="50" y1="0" y2="25" /> + <circle cx="50" cy="50" r="22" /> + <line x1="50" x2="50" y1="75" y2="100" /> + </g> + <g fill={coercedColor}> + <circle cx="85" cy="75" r="10" /> + <path d="m70,100c0,0 15,-30 30,0.25" /> + </g> + </svg> + ); +}; + +export default UserTimelineLogo; diff --git a/FrontEnd/src/views/common/alert/AlertHost.tsx b/FrontEnd/src/views/common/alert/AlertHost.tsx new file mode 100644 index 00000000..949be7ed --- /dev/null +++ b/FrontEnd/src/views/common/alert/AlertHost.tsx @@ -0,0 +1,106 @@ +import React from "react"; +import without from "lodash/without"; +import { useTranslation } from "react-i18next"; +import { Alert } from "react-bootstrap"; + +import { + alertService, + AlertInfoEx, + kAlertHostId, + AlertInfo, +} from "@/services/alert"; +import { convertI18nText } from "@/common"; + +interface AutoCloseAlertProps { + alert: AlertInfo; + close: () => void; +} + +export const AutoCloseAlert: React.FC<AutoCloseAlertProps> = (props) => { + const { alert, close } = props; + const { dismissTime } = alert; + + const { t } = useTranslation(); + + const timerTag = React.useRef<number | null>(null); + const closeHandler = React.useRef<(() => void) | null>(null); + + React.useEffect(() => { + closeHandler.current = close; + }, [close]); + + React.useEffect(() => { + const tag = + dismissTime === "never" + ? null + : typeof dismissTime === "number" + ? window.setTimeout(() => closeHandler.current?.(), dismissTime) + : window.setTimeout(() => closeHandler.current?.(), 5000); + timerTag.current = tag; + return () => { + if (tag != null) { + window.clearTimeout(tag); + } + }; + }, [dismissTime]); + + const cancelTimer = (): void => { + const { current: tag } = timerTag; + if (tag != null) { + window.clearTimeout(tag); + } + }; + + return ( + <Alert + className="m-3" + variant={alert.type ?? "primary"} + onClick={cancelTimer} + onClose={close} + dismissible + > + {(() => { + const { message } = alert; + if (typeof message === "function") { + const Message = message; + return <Message />; + } else return convertI18nText(message, t); + })()} + </Alert> + ); +}; + +const AlertHost: React.FC = () => { + const [alerts, setAlerts] = React.useState<AlertInfoEx[]>([]); + + // react guarantee that state setters are stable, so we don't need to add it to dependency list + + React.useEffect(() => { + const consume = (alert: AlertInfoEx): void => { + setAlerts((old) => [...old, alert]); + }; + + alertService.registerConsumer(consume); + return () => { + alertService.unregisterConsumer(consume); + }; + }, []); + + return ( + <div id={kAlertHostId} className="alert-container"> + {alerts.map((alert) => { + return ( + <AutoCloseAlert + key={alert.id} + alert={alert} + close={() => { + setAlerts((old) => without(old, alert)); + }} + /> + ); + })} + </div> + ); +}; + +export default AlertHost; diff --git a/FrontEnd/src/views/common/alert/alert.sass b/FrontEnd/src/views/common/alert/alert.sass new file mode 100644 index 00000000..c3560b87 --- /dev/null +++ b/FrontEnd/src/views/common/alert/alert.sass @@ -0,0 +1,15 @@ +.alert-container
+ position: fixed
+ z-index: $zindex-popover
+
+@include media-breakpoint-up(sm)
+ .alert-container
+ bottom: 0
+ right: 0
+
+@include media-breakpoint-down(sm)
+ .alert-container
+ bottom: 0
+ right: 0
+ left: 0
+ text-align: center
diff --git a/FrontEnd/src/views/common/common.sass b/FrontEnd/src/views/common/common.sass new file mode 100644 index 00000000..cbf7292e --- /dev/null +++ b/FrontEnd/src/views/common/common.sass @@ -0,0 +1,191 @@ +.image-cropper-container
+ position: relative
+ box-sizing: border-box
+ user-select: none
+
+.image-cropper-container img
+ position: absolute
+ left: 0
+ top: 0
+ width: 100%
+ height: 100%
+
+.image-cropper-mask-container
+ position: absolute
+ left: 0
+ top: 0
+ right: 0
+ bottom: 0
+ overflow: hidden
+
+.image-cropper-mask
+ position: absolute
+ box-shadow: 0 0 0 10000px rgba(255, 255, 255, 80%)
+ touch-action: none
+
+.image-cropper-handler
+ position: absolute
+ width: 26px
+ height: 26px
+ border: black solid 2px
+ border-radius: 50%
+ background: white
+ touch-action: none
+
+.app-bar
+ display: flex
+ align-items: center
+ height: 56px
+
+ position: fixed
+ z-index: 1030
+ top: 0
+ left: 0
+ right: 0
+
+ background-color: var(--tl-primary-color)
+
+ transition: background-color 1s
+
+ a
+ color: var(--tl-text-on-primary-inactive-color)
+ text-decoration: none
+ margin: 0 1em
+
+ &:hover
+ color: var(--tl-text-on-primary-color)
+
+ &.active
+ color: var(--tl-text-on-primary-color)
+
+.app-bar-brand
+ display: flex
+ align-items: center
+
+.app-bar-brand-icon
+ height: 2em
+
+.app-bar-main-area
+ display: flex
+ flex-grow: 1
+
+.app-bar-link-area
+ display: flex
+ align-items: center
+ flex-shrink: 0
+
+.app-bar-user-area
+ display: flex
+ align-items: center
+ flex-shrink: 0
+ margin-left: auto
+
+.small-screen
+ .app-bar-main-area
+ position: absolute
+ top: 56px
+ left: 0
+ right: 0
+
+ transform-origin: top
+ transition: transform 0.6s, background-color 1s
+
+ background-color: var(--tl-primary-color)
+
+ flex-direction: column
+
+ &.app-bar-collapse
+ transform: scale(1,0)
+
+ a
+ text-align: left
+ padding: 0.5em 0.5em
+
+ .app-bar-link-area
+ flex-direction: column
+ align-items: stretch
+
+ .app-bar-user-area
+ flex-direction: column
+ align-items: stretch
+ margin-left: unset
+
+ .app-bar-avatar
+ align-self: flex-end
+
+.app-bar-toggler
+ margin-left: auto
+ font-size: 2em
+ margin-right: 1em
+ color: var(--tl-text-on-primary-color)
+ cursor: pointer
+ user-select: none
+
+.cru-skeleton
+ padding: 0 1em
+
+.cru-skeleton-line
+ height: 1em
+ background-color: #e6e6e6
+ margin: 0.7em 0
+ border-radius: 0.2em
+
+ &.last
+ width: 50%
+
+.cru-full-page
+ position: fixed
+ z-index: 1031
+ left: 0
+ top: 0
+ right: 0
+ bottom: 0
+ background-color: white
+ padding-top: 56px
+
+.cru-full-page-top-bar
+ height: 56px
+
+ position: absolute
+ top: 0
+ left: 0
+ right: 0
+ z-index: 1
+
+ background-color: var(--tl-primary-color)
+
+ display: flex
+ align-items: center
+
+.cru-full-page-content-container
+ overflow: scroll
+
+.cru-menu
+ min-width: 200px
+
+.cru-menu-item
+ font-size: 1.2em
+ padding: 0.5em 1.5em
+ cursor: pointer
+
+ @each $color, $value in $theme-colors
+ &.color-#{$color}
+ color: $value
+
+ &:hover
+ color: white
+ background-color: $value
+
+.cru-menu-item-icon
+ margin-right: 1em
+
+.cru-menu-divider
+ border-top: 1px solid $gray-200
+
+.cru-tab-pages-action-area
+ display: flex
+ align-items: center
+
+.cru-search-input
+ display: flex
+ flex-wrap: wrap
diff --git a/FrontEnd/src/views/common/user/UserAvatar.tsx b/FrontEnd/src/views/common/user/UserAvatar.tsx new file mode 100644 index 00000000..901697db --- /dev/null +++ b/FrontEnd/src/views/common/user/UserAvatar.tsx @@ -0,0 +1,19 @@ +import React from "react"; + +import { getHttpUserClient } from "http/user"; + +export interface UserAvatarProps + extends React.ImgHTMLAttributes<HTMLImageElement> { + username: string; +} + +const UserAvatar: React.FC<UserAvatarProps> = ({ username, ...otherProps }) => { + return ( + <img + src={getHttpUserClient().generateAvatarUrl(username)} + {...otherProps} + /> + ); +}; + +export default UserAvatar; |