aboutsummaryrefslogtreecommitdiff
path: root/FrontEnd/src/views/common
diff options
context:
space:
mode:
Diffstat (limited to 'FrontEnd/src/views/common')
-rw-r--r--FrontEnd/src/views/common/AppBar.tsx80
-rw-r--r--FrontEnd/src/views/common/BlobImage.tsx27
-rw-r--r--FrontEnd/src/views/common/ConfirmDialog.tsx40
-rw-r--r--FrontEnd/src/views/common/FlatButton.tsx36
-rw-r--r--FrontEnd/src/views/common/FullPage.tsx39
-rw-r--r--FrontEnd/src/views/common/ImageCropper.tsx306
-rw-r--r--FrontEnd/src/views/common/LoadFailReload.tsx37
-rw-r--r--FrontEnd/src/views/common/LoadingButton.tsx29
-rw-r--r--FrontEnd/src/views/common/LoadingPage.tsx12
-rw-r--r--FrontEnd/src/views/common/Menu.tsx92
-rw-r--r--FrontEnd/src/views/common/OperationDialog.tsx471
-rw-r--r--FrontEnd/src/views/common/SearchInput.tsx78
-rw-r--r--FrontEnd/src/views/common/Skeleton.tsx30
-rw-r--r--FrontEnd/src/views/common/TabPages.tsx74
-rw-r--r--FrontEnd/src/views/common/TimelineLogo.tsx26
-rw-r--r--FrontEnd/src/views/common/ToggleIconButton.tsx30
-rw-r--r--FrontEnd/src/views/common/UserTimelineLogo.tsx26
-rw-r--r--FrontEnd/src/views/common/alert/AlertHost.tsx106
-rw-r--r--FrontEnd/src/views/common/alert/alert.sass15
-rw-r--r--FrontEnd/src/views/common/common.sass191
-rw-r--r--FrontEnd/src/views/common/user/UserAvatar.tsx19
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;