aboutsummaryrefslogtreecommitdiff
path: root/FrontEnd/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'FrontEnd/src/components')
-rw-r--r--FrontEnd/src/components/AppBar.css87
-rw-r--r--FrontEnd/src/components/AppBar.tsx98
-rw-r--r--FrontEnd/src/components/BlobImage.tsx26
-rw-r--r--FrontEnd/src/components/Card.css20
-rw-r--r--FrontEnd/src/components/Card.tsx38
-rw-r--r--FrontEnd/src/components/Icon.css3
-rw-r--r--FrontEnd/src/components/Icon.tsx30
-rw-r--r--FrontEnd/src/components/ImageCropper.css38
-rw-r--r--FrontEnd/src/components/ImageCropper.tsx312
-rw-r--r--FrontEnd/src/components/LoadFailReload.tsx37
-rw-r--r--FrontEnd/src/components/LoadingPage.tsx13
-rw-r--r--FrontEnd/src/components/Page.tsx15
-rw-r--r--FrontEnd/src/components/SearchInput.css8
-rw-r--r--FrontEnd/src/components/SearchInput.tsx50
-rw-r--r--FrontEnd/src/components/Skeleton.css14
-rw-r--r--FrontEnd/src/components/Skeleton.tsx32
-rw-r--r--FrontEnd/src/components/Spinner.css13
-rw-r--r--FrontEnd/src/components/Spinner.tsx36
-rw-r--r--FrontEnd/src/components/TimelineLogo.tsx27
-rw-r--r--FrontEnd/src/components/alert/AlertHost.tsx113
-rw-r--r--FrontEnd/src/components/alert/alert.css33
-rw-r--r--FrontEnd/src/components/breakpoints.ts3
-rw-r--r--FrontEnd/src/components/button/Button.css64
-rw-r--r--FrontEnd/src/components/button/Button.tsx46
-rw-r--r--FrontEnd/src/components/button/ButtonRow.css0
-rw-r--r--FrontEnd/src/components/button/ButtonRow.tsx62
-rw-r--r--FrontEnd/src/components/button/ButtonRowV2.tsx143
-rw-r--r--FrontEnd/src/components/button/FlatButton.css27
-rw-r--r--FrontEnd/src/components/button/FlatButton.tsx36
-rw-r--r--FrontEnd/src/components/button/IconButton.css30
-rw-r--r--FrontEnd/src/components/button/IconButton.tsx30
-rw-r--r--FrontEnd/src/components/button/LoadingButton.css13
-rw-r--r--FrontEnd/src/components/button/LoadingButton.tsx40
-rw-r--r--FrontEnd/src/components/button/index.tsx15
-rw-r--r--FrontEnd/src/components/common.ts14
-rw-r--r--FrontEnd/src/components/dialog/ConfirmDialog.css0
-rw-r--r--FrontEnd/src/components/dialog/ConfirmDialog.tsx59
-rw-r--r--FrontEnd/src/components/dialog/Dialog.css60
-rw-r--r--FrontEnd/src/components/dialog/Dialog.tsx63
-rw-r--r--FrontEnd/src/components/dialog/DialogContainer.css20
-rw-r--r--FrontEnd/src/components/dialog/DialogContainer.tsx95
-rw-r--r--FrontEnd/src/components/dialog/FullPageDialog.css44
-rw-r--r--FrontEnd/src/components/dialog/FullPageDialog.tsx53
-rw-r--r--FrontEnd/src/components/dialog/OperationDialog.css8
-rw-r--r--FrontEnd/src/components/dialog/OperationDialog.tsx230
-rw-r--r--FrontEnd/src/components/dialog/index.ts64
-rw-r--r--FrontEnd/src/components/hooks.ts14
-rw-r--r--FrontEnd/src/components/index.css100
-rw-r--r--FrontEnd/src/components/input/InputGroup.css54
-rw-r--r--FrontEnd/src/components/input/InputGroup.tsx463
-rw-r--r--FrontEnd/src/components/input/index.ts11
-rw-r--r--FrontEnd/src/components/list/ListContainer.css4
-rw-r--r--FrontEnd/src/components/list/ListContainer.tsx23
-rw-r--r--FrontEnd/src/components/list/ListItemContainer.css3
-rw-r--r--FrontEnd/src/components/list/ListItemContainer.tsx23
-rw-r--r--FrontEnd/src/components/list/index.ts4
-rw-r--r--FrontEnd/src/components/menu/Menu.css36
-rw-r--r--FrontEnd/src/components/menu/Menu.tsx62
-rw-r--r--FrontEnd/src/components/menu/PopupMenu.css7
-rw-r--r--FrontEnd/src/components/menu/PopupMenu.tsx73
-rw-r--r--FrontEnd/src/components/tab/TabPages.tsx71
-rw-r--r--FrontEnd/src/components/tab/Tabs.css33
-rw-r--r--FrontEnd/src/components/tab/Tabs.tsx62
-rw-r--r--FrontEnd/src/components/theme-color.css173
-rw-r--r--FrontEnd/src/components/theme.css146
-rw-r--r--FrontEnd/src/components/user/UserAvatar.tsx22
66 files changed, 3646 insertions, 0 deletions
diff --git a/FrontEnd/src/components/AppBar.css b/FrontEnd/src/components/AppBar.css
new file mode 100644
index 00000000..a0d975b5
--- /dev/null
+++ b/FrontEnd/src/components/AppBar.css
@@ -0,0 +1,87 @@
+.app-bar {
+ height: 56px;
+ position: fixed;
+ z-index: 1030;
+ top: 0;
+ left: 0;
+ right: 0;
+ background-color: var(--cru-primary-color);
+}
+
+.app-bar {
+ display: flex;
+}
+
+.app-bar .app-bar-brand {
+ display: flex;
+ align-items: center;
+}
+
+.app-bar .app-bar-brand-icon {
+ height: 2em;
+}
+
+.app-bar .app-bar-user-area {
+ display: flex;
+ margin-left: auto;
+}
+
+.app-bar a {
+ background-color: var(--cru-primary-color);
+ color: var(--cru-push-button-text-color);
+ text-decoration: none;
+ display: flex;
+ align-items: center;
+ padding: 0 1em;
+ transition: all 0.5s;
+}
+
+.app-bar a:hover {
+ background-color: var(--cru-clickable-primary-hover-color);
+}
+
+.app-bar a:focus {
+ background-color: var(--cru-clickable-primary-focus-color);
+}
+
+.app-bar a:active {
+ background-color: var(--cru-clickable-primary-active-color);
+}
+
+/* the current page */
+.app-bar a.active {
+ background-color: var(--cru-clickable-primary-focus-color);
+}
+
+.app-bar .app-bar-avatar img {
+ width: 45px;
+ height: 45px;
+ background-color: white;
+ border-radius: 50%;
+}
+
+.app-bar.desktop .app-bar-link-area {
+ display: flex;
+}
+
+.app-bar.mobile .app-bar-link-area {
+ position: absolute;
+ z-index: -1;
+ top: 56px;
+ left: 0;
+ right: 0;
+ transition: transform 0.5s;
+}
+
+.app-bar.mobile a {
+ height: 56px;
+}
+
+.app-bar.mobile.collapse .app-bar-link-area {
+ transform: translateY(-100%);
+}
+
+.app-bar .toggler {
+ font-size: 2em;
+ margin-right: 0.5em;
+} \ No newline at end of file
diff --git a/FrontEnd/src/components/AppBar.tsx b/FrontEnd/src/components/AppBar.tsx
new file mode 100644
index 00000000..da3a946f
--- /dev/null
+++ b/FrontEnd/src/components/AppBar.tsx
@@ -0,0 +1,98 @@
+import { useState } from "react";
+import classnames from "classnames";
+import { Link, NavLink } from "react-router-dom";
+
+import { I18nText, useC, useMobile } from "./common";
+import { useUser } from "~src/services/user";
+
+import TimelineLogo from "./TimelineLogo";
+import { IconButton } from "./button";
+import UserAvatar from "./user/UserAvatar";
+
+import "./AppBar.css";
+
+function AppBarNavLink({
+ link,
+ className,
+ label,
+ onClick,
+ children,
+}: {
+ link: string;
+ className?: string;
+ label?: I18nText;
+ onClick?: () => void;
+ children?: React.ReactNode;
+}) {
+ if (label != null && children != null) {
+ throw new Error("AppBarNavLink: label and children cannot be both set");
+ }
+
+ const c = useC();
+
+ return (
+ <NavLink
+ to={link}
+ className={({ isActive }) => classnames(className, isActive && "active")}
+ onClick={onClick}
+ >
+ {children != null ? children : c(label)}
+ </NavLink>
+ );
+}
+
+export default function AppBar() {
+ const isMobile = useMobile();
+
+ const [isCollapse, setIsCollapse] = useState<boolean>(true);
+ const collapse = isMobile ? () => setIsCollapse(true) : undefined;
+ const toggleCollapse = () => setIsCollapse(!isCollapse);
+
+ const user = useUser();
+ const hasAdministrationPermission = user && user.hasAdministrationPermission;
+
+ return (
+ <nav
+ className={classnames(
+ isMobile ? "mobile" : "desktop",
+ "app-bar",
+ isCollapse && "collapse",
+ )}
+ >
+ <Link to="/" className="app-bar-brand" onClick={collapse}>
+ <TimelineLogo className="app-bar-brand-icon" />
+ Timeline
+ </Link>
+
+ <div className="app-bar-link-area">
+ <AppBarNavLink
+ link="/settings"
+ label="nav.settings"
+ onClick={collapse}
+ />
+ <AppBarNavLink link="/about" label="nav.about" onClick={collapse} />
+ {hasAdministrationPermission && (
+ <AppBarNavLink
+ link="/admin"
+ label="nav.administration"
+ onClick={collapse}
+ />
+ )}
+ </div>
+
+ <div className="app-bar-user-area">
+ {user != null ? (
+ <AppBarNavLink link="/" className="app-bar-avatar" onClick={collapse}>
+ <UserAvatar username={user.username} />
+ </AppBarNavLink>
+ ) : (
+ <AppBarNavLink link="/login" label="nav.login" onClick={collapse} />
+ )}
+ </div>
+
+ {isMobile && (
+ <IconButton icon="list" className="toggler" onClick={toggleCollapse} />
+ )}
+ </nav>
+ );
+}
diff --git a/FrontEnd/src/components/BlobImage.tsx b/FrontEnd/src/components/BlobImage.tsx
new file mode 100644
index 00000000..259c2210
--- /dev/null
+++ b/FrontEnd/src/components/BlobImage.tsx
@@ -0,0 +1,26 @@
+import { ComponentPropsWithoutRef, useState, useEffect } from "react";
+
+type BlobImageProps = Omit<ComponentPropsWithoutRef<"img">, "src"> & {
+ imgRef?: React.Ref<HTMLImageElement>;
+ src?: Blob | string | null;
+};
+
+export default function BlobImage(props: BlobImageProps) {
+ const { imgRef, src, ...otherProps } = props;
+
+ const [url, setUrl] = useState<string | null | undefined>(undefined);
+
+ useEffect(() => {
+ if (src instanceof Blob) {
+ const url = URL.createObjectURL(src);
+ setUrl(url);
+ return () => {
+ URL.revokeObjectURL(url);
+ };
+ } else {
+ setUrl(src);
+ }
+ }, [src]);
+
+ return <img ref={imgRef} {...otherProps} src={url ?? undefined} />;
+}
diff --git a/FrontEnd/src/components/Card.css b/FrontEnd/src/components/Card.css
new file mode 100644
index 00000000..6d655eb9
--- /dev/null
+++ b/FrontEnd/src/components/Card.css
@@ -0,0 +1,20 @@
+.cru-card {
+ border-radius: var(--cru-card-border-radius);
+ transition: all 0.3s;
+}
+
+.cru-card-background-none {
+ background-color: transparent;
+}
+
+.cru-card-background-solid {
+ background-color: var(--cru-background-color);
+}
+
+.cru-card-background-grayscale {
+ background-color: var(--cru-container-background-color);
+}
+
+.cru-card-border-color {
+ border: 2px solid var(--cru-card-border-color);
+}
diff --git a/FrontEnd/src/components/Card.tsx b/FrontEnd/src/components/Card.tsx
new file mode 100644
index 00000000..a8f0d3cc
--- /dev/null
+++ b/FrontEnd/src/components/Card.tsx
@@ -0,0 +1,38 @@
+import { ComponentPropsWithoutRef, Ref } from "react";
+import classNames from "classnames";
+
+import { ThemeColor } from "./common";
+import "./Card.css";
+
+interface CardProps extends ComponentPropsWithoutRef<"div"> {
+ containerRef?: Ref<HTMLDivElement>;
+ color?: ThemeColor;
+ border?: "color" | "none";
+ background?: "color" | "solid" | "grayscale" | "none";
+}
+
+export default function Card({
+ color,
+ background,
+ border,
+ className,
+ children,
+ containerRef,
+ ...otherProps
+}: CardProps) {
+ return (
+ <div
+ ref={containerRef}
+ className={classNames(
+ "cru-card",
+ `cru-card-${color ?? "primary"}`,
+ `cru-card-border-${border ?? "color"}`,
+ `cru-card-background-${background ?? "solid"}`,
+ className,
+ )}
+ {...otherProps}
+ >
+ {children}
+ </div>
+ );
+}
diff --git a/FrontEnd/src/components/Icon.css b/FrontEnd/src/components/Icon.css
new file mode 100644
index 00000000..fe980d7b
--- /dev/null
+++ b/FrontEnd/src/components/Icon.css
@@ -0,0 +1,3 @@
+.cru-icon {
+ font-size: 1.4rem;
+}
diff --git a/FrontEnd/src/components/Icon.tsx b/FrontEnd/src/components/Icon.tsx
new file mode 100644
index 00000000..2ac3a7ca
--- /dev/null
+++ b/FrontEnd/src/components/Icon.tsx
@@ -0,0 +1,30 @@
+import { ComponentPropsWithoutRef } from "react";
+import classNames from "classnames";
+
+import { ThemeColor } from "./common";
+
+import "./Icon.css";
+
+interface IconButtonProps extends ComponentPropsWithoutRef<"i"> {
+ icon: string;
+ color?: ThemeColor | "on-surface";
+ size?: string | number;
+}
+
+export default function Icon(props: IconButtonProps) {
+ const { icon, color, size, style, className, ...otherProps } = props;
+
+ const colorName = color === "on-surface" ? "surface-on" : color;
+
+ return (
+ <i
+ style={size != null ? { ...style, fontSize: size } : style}
+ className={classNames(
+ colorName && `cru-${colorName}`,
+ `bi-${icon} cru-icon`,
+ className,
+ )}
+ {...otherProps}
+ />
+ );
+}
diff --git a/FrontEnd/src/components/ImageCropper.css b/FrontEnd/src/components/ImageCropper.css
new file mode 100644
index 00000000..2c4d0a8c
--- /dev/null
+++ b/FrontEnd/src/components/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/components/ImageCropper.tsx b/FrontEnd/src/components/ImageCropper.tsx
new file mode 100644
index 00000000..f23994e2
--- /dev/null
+++ b/FrontEnd/src/components/ImageCropper.tsx
@@ -0,0 +1,312 @@
+import * as React from "react";
+import classnames from "classnames";
+
+import { UiLogicError } from "~src/common";
+
+import "./ImageCropper.css";
+import BlobImage from "./BlobImage";
+
+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;
+ image: string | Blob;
+ onChange: (clip: Clip) => void;
+ imageElementCallback?: (element: HTMLImageElement | null) => void;
+ className?: string;
+}
+
+const ImageCropper = (props: ImageCropperProps): React.ReactElement => {
+ const { clip, image, 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}
+ >
+ <BlobImage
+ imgRef={onImageRef}
+ src={image}
+ onLoad={onImageLoad}
+ alt="to crop"
+ />
+ <div className="image-cropper-mask-container">
+ <div
+ className="image-cropper-mask"
+ 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"
+ 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/components/LoadFailReload.tsx b/FrontEnd/src/components/LoadFailReload.tsx
new file mode 100644
index 00000000..81ba1f67
--- /dev/null
+++ b/FrontEnd/src/components/LoadFailReload.tsx
@@ -0,0 +1,37 @@
+import * as 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/components/LoadingPage.tsx b/FrontEnd/src/components/LoadingPage.tsx
new file mode 100644
index 00000000..35ee1aa8
--- /dev/null
+++ b/FrontEnd/src/components/LoadingPage.tsx
@@ -0,0 +1,13 @@
+import * as React from "react";
+
+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 />
+ </div>
+ );
+};
+
+export default LoadingPage;
diff --git a/FrontEnd/src/components/Page.tsx b/FrontEnd/src/components/Page.tsx
new file mode 100644
index 00000000..86fdb2f5
--- /dev/null
+++ b/FrontEnd/src/components/Page.tsx
@@ -0,0 +1,15 @@
+import { ComponentPropsWithoutRef, Ref } from "react";
+import classNames from "classnames";
+
+interface PageProps extends ComponentPropsWithoutRef<"div"> {
+ noTopPadding?: boolean;
+ pageRef?: Ref<HTMLDivElement>;
+}
+
+export default function Page({ noTopPadding, pageRef, className, children }: PageProps) {
+ return (
+ <div ref={pageRef} className={classNames(className, "cru-page", noTopPadding && "cru-page-no-top-padding")}>
+ {children}
+ </div>
+ );
+}
diff --git a/FrontEnd/src/components/SearchInput.css b/FrontEnd/src/components/SearchInput.css
new file mode 100644
index 00000000..f0503016
--- /dev/null
+++ b/FrontEnd/src/components/SearchInput.css
@@ -0,0 +1,8 @@
+.cru-search-input {
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.cru-search-input-input {
+ width: 100%;
+}
diff --git a/FrontEnd/src/components/SearchInput.tsx b/FrontEnd/src/components/SearchInput.tsx
new file mode 100644
index 00000000..e3216b86
--- /dev/null
+++ b/FrontEnd/src/components/SearchInput.tsx
@@ -0,0 +1,50 @@
+import classNames from "classnames";
+
+import { useC, Text } from "./common";
+import LoadingButton from "./button/LoadingButton";
+
+import "./SearchInput.css";
+
+interface SearchInputProps {
+ value: string;
+ onChange: (value: string) => void;
+ onButtonClick: () => void;
+ loading?: boolean;
+ className?: string;
+ buttonText?: Text;
+}
+
+export default function SearchInput({
+ value,
+ onChange,
+ onButtonClick,
+ loading,
+ className,
+ buttonText,
+}: SearchInputProps) {
+ const c = useC();
+
+ return (
+ <div className={classNames("cru-search-input", className)}>
+ <input
+ type="text"
+ className="cru-search-input-input"
+ value={value}
+ onChange={(event) => {
+ const { value } = event.currentTarget;
+ onChange(value);
+ }}
+ onKeyDown={(event) => {
+ if (event.key === "Enter") {
+ onButtonClick();
+ event.preventDefault();
+ }
+ }}
+ />
+
+ <LoadingButton loading={loading} onClick={onButtonClick}>
+ {c(buttonText ?? "search")}
+ </LoadingButton>
+ </div>
+ );
+}
diff --git a/FrontEnd/src/components/Skeleton.css b/FrontEnd/src/components/Skeleton.css
new file mode 100644
index 00000000..a571eead
--- /dev/null
+++ b/FrontEnd/src/components/Skeleton.css
@@ -0,0 +1,14 @@
+.cru-skeleton {
+ padding: 0 1em;
+}
+
+.cru-skeleton-line {
+ height: 1em;
+ background-color: hsl(0, 0%, 90%);
+ margin: 0.7em 0;
+ border-radius: 0.2em;
+}
+
+.cru-skeleton-line.last {
+ width: 50%;
+}
diff --git a/FrontEnd/src/components/Skeleton.tsx b/FrontEnd/src/components/Skeleton.tsx
new file mode 100644
index 00000000..3b149db9
--- /dev/null
+++ b/FrontEnd/src/components/Skeleton.tsx
@@ -0,0 +1,32 @@
+import * as React from "react";
+import classnames from "classnames";
+import range from "lodash/range";
+
+import "./Skeleton.css";
+
+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/components/Spinner.css b/FrontEnd/src/components/Spinner.css
new file mode 100644
index 00000000..a1de68d2
--- /dev/null
+++ b/FrontEnd/src/components/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/components/Spinner.tsx b/FrontEnd/src/components/Spinner.tsx
new file mode 100644
index 00000000..ec0c2c35
--- /dev/null
+++ b/FrontEnd/src/components/Spinner.tsx
@@ -0,0 +1,36 @@
+import { CSSProperties } from "react";
+import classnames from "classnames";
+
+import { ThemeColor } from "./common";
+
+import "./Spinner.css";
+
+export interface SpinnerProps {
+ size?: "sm" | "md" | "lg" | number | string;
+ color?: ThemeColor;
+ className?: string;
+ style?: CSSProperties;
+}
+
+export default function Spinner(props: SpinnerProps) {
+ 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;
+
+ return (
+ <span
+ className={classnames("cru-spinner", color && `cru-${color}`, className)}
+ style={{ width: calculatedSize, height: calculatedSize, ...style }}
+ />
+ );
+}
diff --git a/FrontEnd/src/components/TimelineLogo.tsx b/FrontEnd/src/components/TimelineLogo.tsx
new file mode 100644
index 00000000..e06ed0f5
--- /dev/null
+++ b/FrontEnd/src/components/TimelineLogo.tsx
@@ -0,0 +1,27 @@
+import { SVGAttributes } from "react";
+import * as React 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/components/alert/AlertHost.tsx b/FrontEnd/src/components/alert/AlertHost.tsx
new file mode 100644
index 00000000..b234ac03
--- /dev/null
+++ b/FrontEnd/src/components/alert/AlertHost.tsx
@@ -0,0 +1,113 @@
+import * as React from "react";
+import without from "lodash/without";
+import { useTranslation } from "react-i18next";
+import classNames from "classnames";
+
+import { alertService, AlertInfoEx, AlertInfo } from "~src/services/alert";
+import { convertI18nText } from "~src/common";
+
+import IconButton from "../button/IconButton";
+
+import "./alert.css";
+
+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 (
+ <div
+ className={classNames(
+ "m-3 cru-alert",
+ "cru-" + (alert.type ?? "primary")
+ )}
+ onClick={cancelTimer}
+ >
+ <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">
+ <IconButton
+ icon="x"
+ className="cru-alert-close-button"
+ onClick={close}
+ />
+ </div>
+ </div>
+ );
+};
+
+const AlertHost: React.FC = () => {
+ const [alerts, setAlerts] = React.useState<AlertInfoEx[]>([]);
+
+ React.useEffect(() => {
+ const consume = (alert: AlertInfoEx): void => {
+ setAlerts((old) => [...old, alert]);
+ };
+
+ alertService.registerConsumer(consume);
+ return () => {
+ alertService.unregisterConsumer(consume);
+ };
+ }, []);
+
+ return (
+ <div 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/components/alert/alert.css b/FrontEnd/src/components/alert/alert.css
new file mode 100644
index 00000000..54c2b87f
--- /dev/null
+++ b/FrontEnd/src/components/alert/alert.css
@@ -0,0 +1,33 @@
+.alert-container {
+ position: fixed;
+ z-index: 1040;
+}
+
+.cru-alert {
+ border-radius: 5px;
+ border: var(--cru-key-color) 1px solid;
+ color: var(--cru-key-t-color);
+ background-color: var(--cru-key-b1-color);
+
+ display: flex;
+ overflow: hidden;
+}
+
+.cru-alert-content {
+ padding: 0.5em 2em;
+}
+
+.cru-alert-close-button-container {
+ flex-shrink: 0;
+ margin-left: auto;
+ width: 2em;
+ text-align: center;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background-color: var(--cru-key-t-color);
+}
+
+.cru-alert-close-button {
+ color: var(--cru-key-color);
+}
diff --git a/FrontEnd/src/components/breakpoints.ts b/FrontEnd/src/components/breakpoints.ts
new file mode 100644
index 00000000..fb281610
--- /dev/null
+++ b/FrontEnd/src/components/breakpoints.ts
@@ -0,0 +1,3 @@
+export const breakpoints = {
+ sm: 576,
+} as const;
diff --git a/FrontEnd/src/components/button/Button.css b/FrontEnd/src/components/button/Button.css
new file mode 100644
index 00000000..1da70f0e
--- /dev/null
+++ b/FrontEnd/src/components/button/Button.css
@@ -0,0 +1,64 @@
+.cru-button {
+ font-size: 1rem;
+ padding: 0.4em 0.8em;
+ transition: all 0.3s;
+ border-radius: 0.2em;
+ border: 1px solid;
+ cursor: pointer;
+}
+
+.cru-button:not(.outline) {
+ color: var(--cru-push-button-text-color);
+ background-color: var(--cru-clickable-normal-color);
+ border-color: var(--cru-clickable-normal-color);
+}
+
+.cru-button:not(.outline):hover {
+ background-color: var(--cru-clickable-hover-color);
+ border-color: var(--cru-clickable-hover-color);
+}
+
+.cru-button:not(.outline):focus {
+ background-color: var(--cru-clickable-focus-color);
+ border-color: var(--cru-clickable-focus-color);
+}
+
+.cru-button:not(.outline):active {
+ background-color: var(--cru-clickable-active-color);
+ border-color: var(--cru-clickable-active-color);
+}
+
+.cru-button:not(.outline):disabled {
+ color: var(--cru-push-button-disabled-text-color);
+ background-color: var(--cru-push-button-disabled-color);
+ border-color: var(--cru-push-button-disabled-color);
+ cursor: auto;
+}
+
+
+.cru-button.outline {
+ color: var(--cru-clickable-normal-color);
+ border-color: var(--cru-clickable-normal-color);
+ background-color: transparent;
+}
+
+.cru-button.outline:hover {
+ color: var(--cru-clickable-hover-color);
+ border-color: var(--cru-clickable-hover-color);
+}
+
+.cru-button.outline:focus {
+ color: var(--cru-clickable-focus-color);
+ border-color: var(--cru-clickable-focus-color);
+}
+
+.cru-button.outline:active {
+ color: var(--cru-clickable-active-color);
+ border-color: var(--cru-clickable-active-color);
+}
+
+.cru-button.outline:disabled {
+ color: var(--cru-clickable-disabled-color);
+ border-color: var(--cru-clickable-disabled-color);
+ cursor: auto;
+}
diff --git a/FrontEnd/src/components/button/Button.tsx b/FrontEnd/src/components/button/Button.tsx
new file mode 100644
index 00000000..6c38e130
--- /dev/null
+++ b/FrontEnd/src/components/button/Button.tsx
@@ -0,0 +1,46 @@
+import { ComponentPropsWithoutRef, Ref } from "react";
+import classNames from "classnames";
+
+import { Text, useC, ThemeColor } from "../common";
+
+import "./Button.css";
+
+interface ButtonProps extends ComponentPropsWithoutRef<"button"> {
+ color?: ThemeColor;
+ text?: Text;
+ outline?: boolean;
+ buttonRef?: Ref<HTMLButtonElement> | null;
+}
+
+export default function Button(props: ButtonProps) {
+ const {
+ buttonRef,
+ color,
+ text,
+ outline,
+ className,
+ children,
+ ...otherProps
+ } = props;
+
+ if (text != null && children != null) {
+ console.warn("You can't set both text and children props.");
+ }
+
+ const c = useC();
+
+ return (
+ <button
+ ref={buttonRef}
+ className={classNames(
+ "cru-button",
+ `cru-clickable-${color ?? "primary"}`,
+ outline && "outline",
+ className,
+ )}
+ {...otherProps}
+ >
+ {text != null ? c(text) : children}
+ </button>
+ );
+}
diff --git a/FrontEnd/src/components/button/ButtonRow.css b/FrontEnd/src/components/button/ButtonRow.css
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/FrontEnd/src/components/button/ButtonRow.css
diff --git a/FrontEnd/src/components/button/ButtonRow.tsx b/FrontEnd/src/components/button/ButtonRow.tsx
new file mode 100644
index 00000000..eea60cc4
--- /dev/null
+++ b/FrontEnd/src/components/button/ButtonRow.tsx
@@ -0,0 +1,62 @@
+import { ComponentPropsWithoutRef, Ref } from "react";
+import classNames from "classnames";
+
+import Button from "./Button";
+import FlatButton from "./FlatButton";
+import IconButton from "./IconButton";
+import LoadingButton from "./LoadingButton";
+
+import "./ButtonRow.css";
+
+type ButtonRowButton = (
+ | {
+ type: "normal";
+ props: ComponentPropsWithoutRef<typeof Button>;
+ }
+ | {
+ type: "flat";
+ props: ComponentPropsWithoutRef<typeof FlatButton>;
+ }
+ | {
+ type: "icon";
+ props: ComponentPropsWithoutRef<typeof IconButton>;
+ }
+ | { type: "loading"; props: ComponentPropsWithoutRef<typeof LoadingButton> }
+) & { key: string | number };
+
+interface ButtonRowProps {
+ className?: string;
+ containerRef?: Ref<HTMLDivElement>;
+ buttons: ButtonRowButton[];
+ buttonsClassName?: string;
+}
+
+export default function ButtonRow({
+ className,
+ containerRef,
+ buttons,
+ buttonsClassName,
+}: ButtonRowProps) {
+ return (
+ <div ref={containerRef} className={classNames("cru-button-row", className)}>
+ {buttons.map((button) => {
+ const { type, key, props } = button;
+ const newClassName = classNames(props.className, buttonsClassName);
+ switch (type) {
+ case "normal":
+ return <Button key={key} {...props} className={newClassName} />;
+ case "flat":
+ return <FlatButton key={key} {...props} className={newClassName} />;
+ case "icon":
+ return <IconButton key={key} {...props} className={newClassName} />;
+ case "loading":
+ return (
+ <LoadingButton key={key} {...props} className={newClassName} />
+ );
+ default:
+ throw new Error();
+ }
+ })}
+ </div>
+ );
+}
diff --git a/FrontEnd/src/components/button/ButtonRowV2.tsx b/FrontEnd/src/components/button/ButtonRowV2.tsx
new file mode 100644
index 00000000..3467ad52
--- /dev/null
+++ b/FrontEnd/src/components/button/ButtonRowV2.tsx
@@ -0,0 +1,143 @@
+import { ComponentPropsWithoutRef, Ref } from "react";
+import classNames from "classnames";
+
+import Button from "./Button";
+import FlatButton from "./FlatButton";
+import IconButton from "./IconButton";
+import LoadingButton from "./LoadingButton";
+
+import "./ButtonRow.css";
+import { Text, ThemeColor } from "../common";
+
+interface ButtonRowV2ButtonBase {
+ key: string | number;
+ action?: "primary" | "secondary";
+ color?: ThemeColor;
+ disabled?: boolean;
+ onClick?: () => void;
+}
+
+interface ButtonRowV2ButtonWithNoType extends ButtonRowV2ButtonBase {
+ type?: undefined | null;
+ text: Text;
+ outline?: boolean;
+ props?: ComponentPropsWithoutRef<typeof Button>;
+}
+
+interface ButtonRowV2NormalButton extends ButtonRowV2ButtonBase {
+ type: "normal";
+ text: Text;
+ outline?: boolean;
+ props?: ComponentPropsWithoutRef<typeof Button>;
+}
+
+interface ButtonRowV2FlatButton extends ButtonRowV2ButtonBase {
+ type: "flat";
+ text: Text;
+ props?: ComponentPropsWithoutRef<typeof FlatButton>;
+}
+
+interface ButtonRowV2IconButton extends ButtonRowV2ButtonBase {
+ type: "icon";
+ icon: string;
+ props?: ComponentPropsWithoutRef<typeof IconButton>;
+}
+
+interface ButtonRowV2LoadingButton extends ButtonRowV2ButtonBase {
+ type: "loading";
+ text: Text;
+ loading?: boolean;
+ props?: ComponentPropsWithoutRef<typeof LoadingButton>;
+}
+
+type ButtonRowV2Button =
+ | ButtonRowV2ButtonWithNoType
+ | ButtonRowV2NormalButton
+ | ButtonRowV2FlatButton
+ | ButtonRowV2IconButton
+ | ButtonRowV2LoadingButton;
+
+interface ButtonRowV2Props {
+ className?: string;
+ containerRef?: Ref<HTMLDivElement>;
+ buttons: ButtonRowV2Button[];
+ buttonsClassName?: string;
+}
+
+export default function ButtonRowV2({
+ className,
+ containerRef,
+ buttons,
+ buttonsClassName,
+}: ButtonRowV2Props) {
+ return (
+ <div ref={containerRef} className={classNames("cru-button-row", className)}>
+ {buttons.map((button) => {
+ const { key, action, color, disabled, onClick } = button;
+
+ const realAction = action ?? "primary";
+ const realColor =
+ color ?? (realAction === "primary" ? "primary" : "secondary");
+
+ const commonProps = { key, color: realColor, disabled, onClick };
+ const newClassName = classNames(
+ button.props?.className,
+ buttonsClassName,
+ );
+
+ switch (button.type) {
+ case null:
+ case undefined:
+ case "normal": {
+ const { text, outline, props } = button;
+ return (
+ <Button
+ {...commonProps}
+ text={text}
+ outline={outline ?? realAction !== "primary"}
+ {...props}
+ className={newClassName}
+ />
+ );
+ }
+ case "flat": {
+ const { text, props } = button;
+ return (
+ <FlatButton
+ {...commonProps}
+ text={text}
+ {...props}
+ className={newClassName}
+ />
+ );
+ }
+ case "icon": {
+ const { icon, props } = button;
+ return (
+ <IconButton
+ {...commonProps}
+ icon={icon}
+ {...props}
+ className={newClassName}
+ />
+ );
+ }
+ case "loading": {
+ const { text, loading, props } = button;
+ return (
+ <LoadingButton
+ {...commonProps}
+ text={text}
+ loading={loading}
+ {...props}
+ className={newClassName}
+ />
+ );
+ }
+ default:
+ throw new Error();
+ }
+ })}
+ </div>
+ );
+}
diff --git a/FrontEnd/src/components/button/FlatButton.css b/FrontEnd/src/components/button/FlatButton.css
new file mode 100644
index 00000000..2050946c
--- /dev/null
+++ b/FrontEnd/src/components/button/FlatButton.css
@@ -0,0 +1,27 @@
+.cru-flat-button {
+ font-size: 1rem;
+ padding: 0.4em 0.8em;
+ transition: all 0.5s;
+ border-radius: 0.2em;
+ background-color: var(--cru-clickable-grayscale-normal-color);
+ border: 1px none;
+ color: var(--cru-clickable-normal-color);
+ cursor: pointer;
+}
+
+.cru-flat-button:hover {
+ background-color: var(--cru-clickable-grayscale-hover-color);
+}
+
+.cru-flat-button:focus {
+ background-color: var(--cru-clickable-grayscale-focus-color);
+}
+
+.cru-flat-button:active {
+ background-color: var(--cru-clickable-grayscale-active-color);
+}
+
+.cru-flat-button:disabled {
+ color: var(--cru-clickable-disabled-color);
+ cursor: auto;
+} \ No newline at end of file
diff --git a/FrontEnd/src/components/button/FlatButton.tsx b/FrontEnd/src/components/button/FlatButton.tsx
new file mode 100644
index 00000000..9f074dd6
--- /dev/null
+++ b/FrontEnd/src/components/button/FlatButton.tsx
@@ -0,0 +1,36 @@
+import { ComponentPropsWithoutRef, Ref } from "react";
+import classNames from "classnames";
+
+import { Text, useC, ThemeColor } from "../common";
+
+import "./FlatButton.css";
+
+interface FlatButtonProps extends ComponentPropsWithoutRef<"button"> {
+ color?: ThemeColor;
+ text?: Text;
+ buttonRef?: Ref<HTMLButtonElement> | null;
+}
+
+export default function FlatButton(props: FlatButtonProps) {
+ const { color, text, className, children, buttonRef, ...otherProps } = props;
+
+ if (text != null && children != null) {
+ console.warn("You can't set both text and children props.");
+ }
+
+ const c = useC();
+
+ return (
+ <button
+ ref={buttonRef}
+ className={classNames(
+ "cru-flat-button",
+ `cru-clickable-${color ?? "primary"}`,
+ className,
+ )}
+ {...otherProps}
+ >
+ {text != null ? c(text) : children}
+ </button>
+ );
+}
diff --git a/FrontEnd/src/components/button/IconButton.css b/FrontEnd/src/components/button/IconButton.css
new file mode 100644
index 00000000..a3747201
--- /dev/null
+++ b/FrontEnd/src/components/button/IconButton.css
@@ -0,0 +1,30 @@
+.cru-icon-button {
+ color: var(--cru-clickable-normal-color);
+ font-size: 1.4rem;
+ background: none;
+ border: none;
+ transition: all 0.5s;
+ cursor: pointer;
+ user-select: none;
+}
+
+.cru-icon-button:hover {
+ color: var(--cru-clickable-hover-color);
+}
+
+.cru-icon-button:focus {
+ color: var(--cru-clickable-focus-color);
+}
+
+.cru-icon-button:active {
+ color: var(--cru-clickable-active-color);
+}
+
+.cru-flat-button:disabled {
+ color: var(--cru-clickable-disabled-color);
+ cursor: auto;
+}
+
+.cru-icon-button.large {
+ font-size: 1.6rem;
+}
diff --git a/FrontEnd/src/components/button/IconButton.tsx b/FrontEnd/src/components/button/IconButton.tsx
new file mode 100644
index 00000000..95c58887
--- /dev/null
+++ b/FrontEnd/src/components/button/IconButton.tsx
@@ -0,0 +1,30 @@
+import { ComponentPropsWithoutRef } from "react";
+import classNames from "classnames";
+
+import { ThemeColor } from "../common";
+
+import "./IconButton.css";
+
+interface IconButtonProps extends ComponentPropsWithoutRef<"i"> {
+ icon: string;
+ color?: ThemeColor | "grayscale";
+ large?: boolean;
+ disabled?: boolean; // TODO: Not implemented
+}
+
+export default function IconButton(props: IconButtonProps) {
+ const { icon, color, className, large, ...otherProps } = props;
+
+ return (
+ <button
+ className={classNames(
+ "cru-icon-button",
+ `cru-clickable-${color ?? "grayscale"}`,
+ large && "large",
+ "bi-" + icon,
+ className,
+ )}
+ {...otherProps}
+ />
+ );
+}
diff --git a/FrontEnd/src/components/button/LoadingButton.css b/FrontEnd/src/components/button/LoadingButton.css
new file mode 100644
index 00000000..23fadd3d
--- /dev/null
+++ b/FrontEnd/src/components/button/LoadingButton.css
@@ -0,0 +1,13 @@
+.cru-loading-button {
+ display: flex;
+ align-items: center;
+}
+
+.cru-loading-button-spinner {
+ margin-left: 0.5em;
+}
+
+.cru-loading-button-loading {
+ color: var(--cru-clickable-normal-color) !important;
+ border-color: var(--cru-clickable-normal-color) !important;
+} \ No newline at end of file
diff --git a/FrontEnd/src/components/button/LoadingButton.tsx b/FrontEnd/src/components/button/LoadingButton.tsx
new file mode 100644
index 00000000..7e7d08e6
--- /dev/null
+++ b/FrontEnd/src/components/button/LoadingButton.tsx
@@ -0,0 +1,40 @@
+import classNames from "classnames";
+
+import { I18nText, ThemeColor, useC } from "../common";
+
+import Spinner from "../Spinner";
+
+import "./LoadingButton.css";
+
+interface LoadingButtonProps extends React.ComponentPropsWithoutRef<"button"> {
+ color?: ThemeColor;
+ text?: I18nText;
+ loading?: boolean;
+}
+
+export default function LoadingButton(props: LoadingButtonProps) {
+ const c = useC();
+
+ const { color, text, loading, disabled, className, children, ...otherProps } =
+ props;
+
+ if (text != null && children != null) {
+ console.warn("You can't set both text and children props.");
+ }
+
+ return (
+ <button
+ disabled={disabled || loading}
+ className={classNames(
+ "cru-button outline cru-loading-button",
+ `cru-clickable-${color ?? "primary"}`,
+ loading && "cru-loading-button-loading",
+ className,
+ )}
+ {...otherProps}
+ >
+ {text != null ? c(text) : children}
+ {loading && <Spinner className="cru-loading-button-spinner" />}
+ </button>
+ );
+}
diff --git a/FrontEnd/src/components/button/index.tsx b/FrontEnd/src/components/button/index.tsx
new file mode 100644
index 00000000..b5aa5470
--- /dev/null
+++ b/FrontEnd/src/components/button/index.tsx
@@ -0,0 +1,15 @@
+import Button from "./Button";
+import FlatButton from "./FlatButton";
+import IconButton from "./IconButton";
+import LoadingButton from "./LoadingButton";
+import ButtonRow from "./ButtonRow";
+import ButtonRowV2 from "./ButtonRowV2";
+
+export {
+ Button,
+ FlatButton,
+ IconButton,
+ LoadingButton,
+ ButtonRow,
+ ButtonRowV2,
+};
diff --git a/FrontEnd/src/components/common.ts b/FrontEnd/src/components/common.ts
new file mode 100644
index 00000000..e6f7319f
--- /dev/null
+++ b/FrontEnd/src/components/common.ts
@@ -0,0 +1,14 @@
+export type { Text, I18nText } from "~src/common";
+export { UiLogicError, c, convertI18nText, useC } from "~src/common";
+
+export const themeColors = [
+ "primary",
+ "secondary",
+ "danger",
+ "create",
+] as const;
+
+export type ThemeColor = (typeof themeColors)[number];
+
+export { breakpoints } from "./breakpoints";
+export { useMobile } from "./hooks";
diff --git a/FrontEnd/src/components/dialog/ConfirmDialog.css b/FrontEnd/src/components/dialog/ConfirmDialog.css
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/FrontEnd/src/components/dialog/ConfirmDialog.css
diff --git a/FrontEnd/src/components/dialog/ConfirmDialog.tsx b/FrontEnd/src/components/dialog/ConfirmDialog.tsx
new file mode 100644
index 00000000..26939c9b
--- /dev/null
+++ b/FrontEnd/src/components/dialog/ConfirmDialog.tsx
@@ -0,0 +1,59 @@
+import { useC, Text, ThemeColor } from "../common";
+
+import Dialog from "./Dialog";
+import DialogContainer from "./DialogContainer";
+
+export default function ConfirmDialog({
+ open,
+ onClose,
+ onConfirm,
+ title,
+ body,
+ color,
+ bodyColor,
+}: {
+ open: boolean;
+ onClose: () => void;
+ onConfirm: () => void;
+ title: Text;
+ body: Text;
+ color?: ThemeColor;
+ bodyColor?: ThemeColor;
+}) {
+ const c = useC();
+
+ return (
+ <Dialog onClose={onClose} open={open}>
+ <DialogContainer
+ title={title}
+ titleColor={color ?? "danger"}
+ buttons={[
+ {
+ key: "cancel",
+ type: "normal",
+ props: {
+ text: "operationDialog.cancel",
+ color: "secondary",
+ outline: true,
+ onClick: onClose,
+ },
+ },
+ {
+ key: "confirm",
+ type: "normal",
+ props: {
+ text: "operationDialog.confirm",
+ color: "danger",
+ onClick: () => {
+ onConfirm();
+ onClose();
+ },
+ },
+ },
+ ]}
+ >
+ <div className={`cru-${bodyColor ?? "primary"}`}>{c(body)}</div>
+ </DialogContainer>
+ </Dialog>
+ );
+}
diff --git a/FrontEnd/src/components/dialog/Dialog.css b/FrontEnd/src/components/dialog/Dialog.css
new file mode 100644
index 00000000..e4c61440
--- /dev/null
+++ b/FrontEnd/src/components/dialog/Dialog.css
@@ -0,0 +1,60 @@
+.cru-dialog-overlay {
+ position: fixed;
+ z-index: 1040;
+ left: 0;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ display: flex;
+ align-items: center;
+ overflow: auto;
+}
+
+.cru-dialog-background {
+ position: absolute;
+ z-index: -1;
+ left: 0;
+ right: 0;
+ top: 0;
+ bottom: 0;
+ background-color: var(--cru-surface-dim-color);
+ opacity: 0.8;
+}
+
+.cru-dialog-container {
+ max-width: 100%;
+ min-width: 30vw;
+
+ margin: 2em auto;
+
+ border: var(--cru-key-container-color) 1px solid;
+ border-radius: 5px;
+ padding: 1.5em;
+ background-color: var(--cru-surface-color);
+}
+
+@media (min-width: 576px) {
+ .cru-dialog-container {
+ max-width: 800px;
+ }
+}
+
+.cru-dialog-enter .cru-dialog-container {
+ transform: scale(0, 0);
+ opacity: 0;
+ transform-origin: center;
+}
+
+.cru-dialog-enter-active .cru-dialog-container {
+ transform: scale(1, 1);
+ opacity: 1;
+ transition: transform 0.3s, opacity 0.3s;
+ transform-origin: center;
+}
+
+.cru-dialog-exit-active .cru-dialog-container {
+ transition: transform 0.3s, opacity 0.3s;
+ transform: scale(0, 0);
+ opacity: 0;
+ transform-origin: center;
+} \ No newline at end of file
diff --git a/FrontEnd/src/components/dialog/Dialog.tsx b/FrontEnd/src/components/dialog/Dialog.tsx
new file mode 100644
index 00000000..2ff7bea8
--- /dev/null
+++ b/FrontEnd/src/components/dialog/Dialog.tsx
@@ -0,0 +1,63 @@
+import { ReactNode, useRef } from "react";
+import ReactDOM from "react-dom";
+import { CSSTransition } from "react-transition-group";
+import classNames from "classnames";
+
+import { ThemeColor } from "../common";
+
+import "./Dialog.css";
+
+const optionalPortalElement = document.getElementById("portal");
+if (optionalPortalElement == null) {
+ throw new Error("Portal element not found");
+}
+const portalElement = optionalPortalElement;
+
+interface DialogProps {
+ open: boolean;
+ onClose: () => void;
+ color?: ThemeColor;
+ children?: ReactNode;
+ disableCloseOnClickOnOverlay?: boolean;
+}
+
+export default function Dialog({
+ open,
+ onClose,
+ color,
+ children,
+ disableCloseOnClickOnOverlay,
+}: DialogProps) {
+ color = color ?? "primary";
+
+ const nodeRef = useRef(null);
+
+ return ReactDOM.createPortal(
+ <CSSTransition
+ nodeRef={nodeRef}
+ mountOnEnter
+ unmountOnExit
+ in={open}
+ timeout={300}
+ classNames="cru-dialog"
+ >
+ <div
+ ref={nodeRef}
+ className={classNames("cru-dialog-overlay", `cru-${color}`)}
+ >
+ <div
+ className="cru-dialog-background"
+ onClick={
+ disableCloseOnClickOnOverlay
+ ? undefined
+ : () => {
+ onClose();
+ }
+ }
+ />
+ <div className="cru-dialog-container">{children}</div>
+ </div>
+ </CSSTransition>,
+ portalElement,
+ );
+}
diff --git a/FrontEnd/src/components/dialog/DialogContainer.css b/FrontEnd/src/components/dialog/DialogContainer.css
new file mode 100644
index 00000000..fbb18e0d
--- /dev/null
+++ b/FrontEnd/src/components/dialog/DialogContainer.css
@@ -0,0 +1,20 @@
+.cru-dialog-container-title {
+ font-size: 1.2em;
+ font-weight: bold;
+ color: var(--cru-key-color);
+ margin-bottom: 0.5em;
+}
+
+
+.cru-dialog-container-hr {
+ margin: 1em 0;
+}
+
+.cru-dialog-container-button-row {
+ display: flex;
+ justify-content: flex-end;
+}
+
+.cru-dialog-container-button {
+ margin-left: 1em;
+} \ No newline at end of file
diff --git a/FrontEnd/src/components/dialog/DialogContainer.tsx b/FrontEnd/src/components/dialog/DialogContainer.tsx
new file mode 100644
index 00000000..afee2669
--- /dev/null
+++ b/FrontEnd/src/components/dialog/DialogContainer.tsx
@@ -0,0 +1,95 @@
+import { ComponentProps, Ref, ReactNode } from "react";
+import classNames from "classnames";
+
+import { ThemeColor, Text, useC } from "../common";
+import { ButtonRow, ButtonRowV2 } from "../button";
+
+import "./DialogContainer.css";
+
+interface DialogContainerBaseProps {
+ className?: string;
+ title: Text;
+ titleColor?: ThemeColor;
+ titleClassName?: string;
+ titleRef?: Ref<HTMLDivElement>;
+ bodyContainerClassName?: string;
+ bodyContainerRef?: Ref<HTMLDivElement>;
+ buttonsClassName?: string;
+ buttonsContainerRef?: ComponentProps<typeof ButtonRow>["containerRef"];
+ children: ReactNode;
+}
+
+interface DialogContainerWithButtonsProps extends DialogContainerBaseProps {
+ buttons: ComponentProps<typeof ButtonRow>["buttons"];
+}
+
+interface DialogContainerWithButtonsV2Props extends DialogContainerBaseProps {
+ buttonsV2: ComponentProps<typeof ButtonRowV2>["buttons"];
+}
+
+type DialogContainerProps =
+ | DialogContainerWithButtonsProps
+ | DialogContainerWithButtonsV2Props;
+
+export default function DialogContainer(props: DialogContainerProps) {
+ const {
+ className,
+ title,
+ titleColor,
+ titleClassName,
+ titleRef,
+ bodyContainerClassName,
+ bodyContainerRef,
+ buttonsClassName,
+ buttonsContainerRef,
+ children,
+ } = props;
+
+ const c = useC();
+
+ return (
+ <div className={classNames(className)}>
+ <div
+ ref={titleRef}
+ className={classNames(
+ `cru-dialog-container-title cru-${titleColor ?? "primary"}`,
+ titleClassName,
+ )}
+ >
+ {c(title)}
+ </div>
+ <hr className="cru-dialog-container-hr" />
+ <div
+ ref={bodyContainerRef}
+ className={classNames(
+ "cru-dialog-container-body",
+ bodyContainerClassName,
+ )}
+ >
+ {children}
+ </div>
+ <hr className="cru-dialog-container-hr" />
+ {"buttons" in props ? (
+ <ButtonRow
+ containerRef={buttonsContainerRef}
+ className={classNames(
+ "cru-dialog-container-button-row",
+ buttonsClassName,
+ )}
+ buttons={props.buttons}
+ buttonsClassName="cru-dialog-container-button"
+ />
+ ) : (
+ <ButtonRowV2
+ containerRef={buttonsContainerRef}
+ className={classNames(
+ "cru-dialog-container-button-row",
+ buttonsClassName,
+ )}
+ buttons={props.buttonsV2}
+ buttonsClassName="cru-dialog-container-button"
+ />
+ )}
+ </div>
+ );
+}
diff --git a/FrontEnd/src/components/dialog/FullPageDialog.css b/FrontEnd/src/components/dialog/FullPageDialog.css
new file mode 100644
index 00000000..2f1fc636
--- /dev/null
+++ b/FrontEnd/src/components/dialog/FullPageDialog.css
@@ -0,0 +1,44 @@
+.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);
+}
+
+.cru-full-page-enter {
+ transform: translate(100%, 0);
+}
+
+.cru-full-page-enter-active {
+ transform: none;
+ transition: transform 0.3s;
+}
+
+.cru-full-page-exit-active {
+ transition: transform 0.3s;
+ transform: translate(100%, 0);
+}
diff --git a/FrontEnd/src/components/dialog/FullPageDialog.tsx b/FrontEnd/src/components/dialog/FullPageDialog.tsx
new file mode 100644
index 00000000..6368fc0a
--- /dev/null
+++ b/FrontEnd/src/components/dialog/FullPageDialog.tsx
@@ -0,0 +1,53 @@
+import * as React from "react";
+import { createPortal } from "react-dom";
+import classnames from "classnames";
+import { CSSTransition } from "react-transition-group";
+
+import "./FullPageDialog.css";
+import IconButton from "../button/IconButton";
+
+export interface FullPageDialogProps {
+ show: boolean;
+ onBack: () => void;
+ contentContainerClassName?: string;
+ children: React.ReactNode;
+}
+
+const FullPageDialog: React.FC<FullPageDialogProps> = ({
+ show,
+ onBack,
+ children,
+ contentContainerClassName,
+}) => {
+ return createPortal(
+ <CSSTransition
+ mountOnEnter
+ unmountOnExit
+ in={show}
+ timeout={300}
+ classNames="cru-full-page"
+ >
+ <div className="cru-full-page">
+ <div className="cru-full-page-top-bar">
+ <IconButton
+ icon="arrow-left"
+ className="ms-3 cru-full-page-back-button"
+ onClick={onBack}
+ />
+ </div>
+ <div
+ className={classnames(
+ "cru-full-page-content-container",
+ contentContainerClassName
+ )}
+ >
+ {children}
+ </div>
+ </div>
+ </CSSTransition>,
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ document.getElementById("portal")!
+ );
+};
+
+export default FullPageDialog;
diff --git a/FrontEnd/src/components/dialog/OperationDialog.css b/FrontEnd/src/components/dialog/OperationDialog.css
new file mode 100644
index 00000000..f4b7237e
--- /dev/null
+++ b/FrontEnd/src/components/dialog/OperationDialog.css
@@ -0,0 +1,8 @@
+.cru-operation-dialog-prompt {
+ color: var(--cru-surface-on-color);
+}
+
+.cru-operation-dialog-input-group {
+ display: block;
+ margin: 0.5em 0;
+}
diff --git a/FrontEnd/src/components/dialog/OperationDialog.tsx b/FrontEnd/src/components/dialog/OperationDialog.tsx
new file mode 100644
index 00000000..e5db7f4f
--- /dev/null
+++ b/FrontEnd/src/components/dialog/OperationDialog.tsx
@@ -0,0 +1,230 @@
+import { useState, ReactNode, ComponentProps } from "react";
+import classNames from "classnames";
+
+import { useC, Text, ThemeColor } from "../common";
+
+import {
+ useInputs,
+ InputGroup,
+ Initializer as InputInitializer,
+ InputValueDict,
+ InputErrorDict,
+ InputConfirmValueDict,
+} from "../input";
+import Dialog from "./Dialog";
+import DialogContainer from "./DialogContainer";
+import { ButtonRow } from "../button";
+
+import "./OperationDialog.css";
+
+export type { InputInitializer, InputValueDict, InputErrorDict };
+
+interface OperationDialogPromptProps {
+ message?: Text;
+ customMessage?: Text;
+ customMessageNode?: ReactNode;
+ className?: string;
+}
+
+function OperationDialogPrompt(props: OperationDialogPromptProps) {
+ const { message, customMessage, customMessageNode, className } = props;
+
+ const c = useC();
+
+ return (
+ <div className={classNames(className, "cru-operation-dialog-prompt")}>
+ {message && <p>{c(message)}</p>}
+ {customMessageNode ?? (customMessage != null ? c(customMessage) : null)}
+ </div>
+ );
+}
+
+export interface OperationDialogProps<TData> {
+ open: boolean;
+ onClose: () => void;
+
+ color?: ThemeColor;
+ inputColor?: ThemeColor;
+ title: Text;
+ inputPrompt?: Text;
+ inputPromptNode?: ReactNode;
+ successPrompt?: (data: TData) => Text;
+ successPromptNode?: (data: TData) => ReactNode;
+ failurePrompt?: (error: unknown) => Text;
+ failurePromptNode?: (error: unknown) => ReactNode;
+
+ inputs: InputInitializer;
+
+ onProcess: (inputs: InputConfirmValueDict) => Promise<TData>;
+ onSuccessAndClose?: (data: TData) => void;
+}
+
+function OperationDialog<TData>(props: OperationDialogProps<TData>) {
+ const {
+ open,
+ onClose,
+ color,
+ inputColor,
+ title,
+ inputPrompt,
+ inputPromptNode,
+ successPrompt,
+ successPromptNode,
+ failurePrompt,
+ failurePromptNode,
+ inputs,
+ onProcess,
+ onSuccessAndClose,
+ } = props;
+
+ if (process.env.NODE_ENV === "development") {
+ if (inputPrompt && inputPromptNode) {
+ console.log("InputPrompt and inputPromptNode are both set.");
+ }
+ if (successPrompt && successPromptNode) {
+ console.log("SuccessPrompt and successPromptNode are both set.");
+ }
+ if (failurePrompt && failurePromptNode) {
+ console.log("FailurePrompt and failurePromptNode are both set.");
+ }
+ }
+
+ type Step =
+ | { type: "input" }
+ | { type: "process" }
+ | {
+ type: "success";
+ data: TData;
+ }
+ | {
+ type: "failure";
+ data: unknown;
+ };
+
+ const [step, setStep] = useState<Step>({ type: "input" });
+
+ const { inputGroupProps, hasErrorAndDirty, setAllDisabled, confirm } =
+ useInputs({
+ init: inputs,
+ });
+
+ function close() {
+ if (step.type !== "process") {
+ onClose();
+ if (step.type === "success" && onSuccessAndClose) {
+ onSuccessAndClose?.(step.data);
+ }
+ } else {
+ console.log("Attempt to close modal dialog when processing.");
+ }
+ }
+
+ function onConfirm() {
+ const result = confirm();
+ if (result.type === "ok") {
+ setStep({ type: "process" });
+ setAllDisabled(true);
+ onProcess(result.values).then(
+ (d) => {
+ setStep({
+ type: "success",
+ data: d,
+ });
+ },
+ (e: unknown) => {
+ setStep({
+ type: "failure",
+ data: e,
+ });
+ },
+ );
+ }
+ }
+
+ let body: ReactNode;
+ let buttons: ComponentProps<typeof ButtonRow>["buttons"];
+
+ if (step.type === "input" || step.type === "process") {
+ const isProcessing = step.type === "process";
+
+ body = (
+ <div>
+ <OperationDialogPrompt
+ customMessage={inputPrompt}
+ customMessageNode={inputPromptNode}
+ />
+ <InputGroup
+ containerClassName="cru-operation-dialog-input-group"
+ color={inputColor ?? "primary"}
+ {...inputGroupProps}
+ />
+ </div>
+ );
+ buttons = [
+ {
+ key: "cancel",
+ type: "normal",
+ props: {
+ text: "operationDialog.cancel",
+ color: "secondary",
+ outline: true,
+ onClick: close,
+ disabled: isProcessing,
+ },
+ },
+ {
+ key: "confirm",
+ type: "loading",
+ props: {
+ text: "operationDialog.confirm",
+ color,
+ loading: isProcessing,
+ disabled: hasErrorAndDirty,
+ onClick: onConfirm,
+ },
+ },
+ ];
+ } else {
+ const result = step;
+
+ const promptProps: OperationDialogPromptProps =
+ result.type === "success"
+ ? {
+ message: "operationDialog.success",
+ customMessage: successPrompt?.(result.data),
+ customMessageNode: successPromptNode?.(result.data),
+ }
+ : {
+ message: "operationDialog.error",
+ customMessage: failurePrompt?.(result.data),
+ customMessageNode: failurePromptNode?.(result.data),
+ };
+ body = (
+ <div>
+ <OperationDialogPrompt {...promptProps} />
+ </div>
+ );
+
+ buttons = [
+ {
+ key: "ok",
+ type: "normal",
+ props: {
+ text: "operationDialog.ok",
+ color: "primary",
+ onClick: close,
+ },
+ },
+ ];
+ }
+
+ return (
+ <Dialog open={open} onClose={close}>
+ <DialogContainer title={title} titleColor={color} buttons={buttons}>
+ {body}
+ </DialogContainer>
+ </Dialog>
+ );
+}
+
+export default OperationDialog;
diff --git a/FrontEnd/src/components/dialog/index.ts b/FrontEnd/src/components/dialog/index.ts
new file mode 100644
index 00000000..59f15791
--- /dev/null
+++ b/FrontEnd/src/components/dialog/index.ts
@@ -0,0 +1,64 @@
+import { useState } from "react";
+
+export { default as Dialog } from "./Dialog";
+export { default as FullPageDialog } from "./FullPageDialog";
+export { default as OperationDialog } from "./OperationDialog";
+export { default as ConfirmDialog } from "./ConfirmDialog";
+
+type DialogMap<D extends string, V> = {
+ [K in D]: V;
+};
+
+type DialogKeyMap<D extends string> = DialogMap<D, number>;
+
+type DialogPropsMap<D extends string> = DialogMap<
+ D,
+ { key: number | string; open: boolean; onClose: () => void }
+>;
+
+export function useDialog<D extends string>(
+ dialogs: D[],
+ options?: {
+ initDialog?: D | null;
+ onClose?: {
+ [K in D]?: () => void;
+ };
+ },
+): {
+ dialog: D | null;
+ switchDialog: (newDialog: D | null) => void;
+ dialogPropsMap: DialogPropsMap<D>;
+ createDialogSwitch: (newDialog: D | null) => () => void;
+} {
+ const [dialog, setDialog] = useState<D | null>(options?.initDialog ?? null);
+
+ const [dialogKeys, setDialogKeys] = useState<DialogKeyMap<D>>(
+ () => Object.fromEntries(dialogs.map((d) => [d, 0])) as DialogKeyMap<D>,
+ );
+
+ const switchDialog = (newDialog: D | null) => {
+ if (dialog !== null) {
+ setDialogKeys({ ...dialogKeys, [dialog]: dialogKeys[dialog] + 1 });
+ }
+ setDialog(newDialog);
+ };
+
+ return {
+ dialog,
+ switchDialog,
+ dialogPropsMap: Object.fromEntries(
+ dialogs.map((d) => [
+ d,
+ {
+ key: `${d}-${dialogKeys[d]}`,
+ open: dialog === d,
+ onClose: () => {
+ switchDialog(null);
+ options?.onClose?.[d]?.();
+ },
+ },
+ ]),
+ ) as DialogPropsMap<D>,
+ createDialogSwitch: (newDialog: D | null) => () => switchDialog(newDialog),
+ };
+}
diff --git a/FrontEnd/src/components/hooks.ts b/FrontEnd/src/components/hooks.ts
new file mode 100644
index 00000000..523a4538
--- /dev/null
+++ b/FrontEnd/src/components/hooks.ts
@@ -0,0 +1,14 @@
+// TODO: Migrate hooks
+
+export {
+ useIsSmallScreen,
+ useClickOutside,
+ useScrollToBottom,
+} from "~src/utilities/hooks";
+
+import { useMediaQuery } from "react-responsive";
+import { breakpoints } from "./breakpoints";
+
+export function useMobile(): boolean {
+ return useMediaQuery({ maxWidth: breakpoints.sm });
+}
diff --git a/FrontEnd/src/components/index.css b/FrontEnd/src/components/index.css
new file mode 100644
index 00000000..a8f5e9a5
--- /dev/null
+++ b/FrontEnd/src/components/index.css
@@ -0,0 +1,100 @@
+@import "./theme.css";
+
+* {
+ box-sizing: border-box;
+ margin-inline: 0;
+ margin-block: 0;
+}
+
+body {
+ font-family: var(--cru-default-font-family);
+ background: var(--cru-body-background-color);
+ color: var(--cru-text-primary-color);
+ line-height: 1.2;
+}
+
+.cru-page {
+ padding: var(--cru-page-padding);
+}
+
+.cru-page-no-top-padding {
+ padding-top: 0;
+}
+
+.cru-text-center {
+ text-align: center;
+}
+
+.cru-text-end {
+ text-align: end;
+}
+
+.cru-float-left {
+ float: left;
+}
+
+.cru-float-right {
+ float: right;
+}
+
+.cru-align-text-bottom {
+ vertical-align: text-bottom;
+}
+
+.cru-align-middle {
+ vertical-align: middle;
+}
+
+.cru-clearfix::after {
+ clear: both;
+}
+
+.cru-fill-parent {
+ width: 100%;
+ height: 100%;
+}
+
+.cru-avatar {
+ width: 60px;
+ height: 60px;
+}
+
+.cru-avatar.large {
+ width: 100px;
+ height: 100px;
+}
+
+.cru-avatar.small {
+ width: 40px;
+ height: 40px;
+}
+
+.cru-round {
+ border-radius: 50%;
+}
+
+.cru-tab-pages-action-area {
+ display: flex;
+ align-items: center;
+}
+
+.alert-container {
+ position: fixed;
+ z-index: 1070;
+}
+
+@media (min-width: 576px) {
+ .alert-container {
+ bottom: 0;
+ right: 0;
+ }
+}
+
+@media (max-width: 575.98px) {
+ .alert-container {
+ bottom: 0;
+ right: 0;
+ left: 0;
+ text-align: center;
+ }
+} \ No newline at end of file
diff --git a/FrontEnd/src/components/input/InputGroup.css b/FrontEnd/src/components/input/InputGroup.css
new file mode 100644
index 00000000..7e905b1e
--- /dev/null
+++ b/FrontEnd/src/components/input/InputGroup.css
@@ -0,0 +1,54 @@
+.cru-input-group {
+ display: block;
+}
+
+.cru-input-container {
+ margin: 0.4em 0;
+}
+
+.cru-input-label {
+ display: block;
+ color: var(--cru-clickable-normal-color);
+ font-size: 0.9em;
+ margin-bottom: 0.3em;
+}
+
+.cru-input-label-inline {
+ margin-inline-start: 0.5em;
+}
+
+.cru-input-type-text input {
+ appearance: none;
+ display: block;
+ border: 1px solid;
+ /* color: var(--cru-surface-on-color); */
+ /* background-color: var(--cru-surface-color); */
+ margin: 0;
+ font-size: 1em;
+ padding: 0.2em;
+}
+
+.cru-input-type-text input:hover {
+ border-color: var(--cru-clickable-hover-color);
+}
+
+.cru-input-type-text input:focus {
+ border-color: var(--cru-clickable-focus-color);
+}
+
+.cru-input-type-text input:disabled {
+ border-color: var(--cru-clickable-disabled-color);
+}
+
+.cru-input-error {
+ display: block;
+ font-size: 0.8em;
+ color: var(--cru-danger-color);
+ margin-top: 0.4em;
+}
+
+.cru-input-helper {
+ display: block;
+ font-size: 0.8em;
+ color: var(--cru-primary-color);
+} \ No newline at end of file
diff --git a/FrontEnd/src/components/input/InputGroup.tsx b/FrontEnd/src/components/input/InputGroup.tsx
new file mode 100644
index 00000000..4f487344
--- /dev/null
+++ b/FrontEnd/src/components/input/InputGroup.tsx
@@ -0,0 +1,463 @@
+/**
+ * Some notes for InputGroup:
+ * This is one of the most complicated components in this project.
+ * Probably because the feature is complex and involved user inputs.
+ *
+ * I hope it contains following features:
+ * - Input features
+ * - Supports a wide range of input types.
+ * - Validator to validate user inputs.
+ * - Can set initial values.
+ * - Dirty, aka, has user touched this input.
+ * - Developer friendly
+ * - Easy to use APIs.
+ * - Type check as much as possible.
+ * - UI
+ * - Configurable appearance.
+ * - Can display helper and error messages.
+ * - Easy to extend, like new input types.
+ *
+ * So here is some design decisions:
+ * Inputs are identified by its _key_.
+ * `InputGroup` component takes care of only UI and no logic.
+ * `useInputs` hook takes care of logic and generate props for `InputGroup`.
+ */
+
+import { useState, Ref, useId } from "react";
+import classNames from "classnames";
+
+import { useC, Text, ThemeColor } from "../common";
+
+import "./InputGroup.css";
+
+export interface InputBase {
+ key: string;
+ label: Text;
+ helper?: Text;
+ disabled?: boolean;
+ error?: Text;
+}
+
+export interface TextInput extends InputBase {
+ type: "text";
+ value: string;
+ password?: boolean;
+}
+
+export interface BoolInput extends InputBase {
+ type: "bool";
+ value: boolean;
+}
+
+export interface SelectInputOption {
+ value: string;
+ label: Text;
+ icon?: string;
+}
+
+export interface SelectInput extends InputBase {
+ type: "select";
+ value: string;
+ options: SelectInputOption[];
+}
+
+export type Input = TextInput | BoolInput | SelectInput;
+
+export type InputValue = Input["value"];
+
+export type InputValueDict = Record<string, InputValue>;
+export type InputErrorDict = Record<string, Text>;
+export type InputDisabledDict = Record<string, boolean>;
+export type InputDirtyDict = Record<string, boolean>;
+// use never so you don't have to cast everywhere
+export type InputConfirmValueDict = Record<string, never>;
+
+export type GeneralInputErrorDict =
+ | {
+ [key: string]: Text | null | undefined;
+ }
+ | null
+ | undefined;
+
+type MakeInputInfo<I extends Input> = Omit<I, "value" | "error" | "disabled">;
+
+export type InputInfo = {
+ [I in Input as I["type"]]: MakeInputInfo<I>;
+}[Input["type"]];
+
+export type Validator = (
+ values: InputValueDict,
+ inputs: InputInfo[],
+) => GeneralInputErrorDict;
+
+export type InputScheme = {
+ inputs: InputInfo[];
+ validator?: Validator;
+};
+
+export type InputData = {
+ values: InputValueDict;
+ errors: InputErrorDict;
+ disabled: InputDisabledDict;
+ dirties: InputDirtyDict;
+};
+
+export type State = {
+ scheme: InputScheme;
+ data: InputData;
+};
+
+export type DataInitialization = {
+ values?: InputValueDict;
+ errors?: GeneralInputErrorDict;
+ disabled?: InputDisabledDict;
+ dirties?: InputDirtyDict;
+};
+
+export type Initialization = {
+ scheme: InputScheme;
+ dataInit?: DataInitialization;
+};
+
+export type GeneralInitialization = Initialization | InputScheme | InputInfo[];
+
+export type Initializer = GeneralInitialization | (() => GeneralInitialization);
+
+export interface InputGroupProps {
+ color?: ThemeColor;
+ containerClassName?: string;
+ containerRef?: Ref<HTMLDivElement>;
+
+ inputs: Input[];
+ onChange: (index: number, value: Input["value"]) => void;
+}
+
+function cleanObject<V>(o: Record<string, V>): Record<string, NonNullable<V>> {
+ const result = { ...o };
+ for (const key of Object.keys(result)) {
+ if (result[key] == null) {
+ delete result[key];
+ }
+ }
+ return result as never;
+}
+
+export type ConfirmResult =
+ | {
+ type: "ok";
+ values: InputConfirmValueDict;
+ }
+ | {
+ type: "error";
+ errors: InputErrorDict;
+ };
+
+function validate(
+ validator: Validator | null | undefined,
+ values: InputValueDict,
+ inputs: InputInfo[],
+): InputErrorDict {
+ return cleanObject(validator?.(values, inputs) ?? {});
+}
+
+export function useInputs(options: { init: Initializer }): {
+ inputGroupProps: InputGroupProps;
+ hasError: boolean;
+ hasErrorAndDirty: boolean;
+ confirm: () => ConfirmResult;
+ setAllDisabled: (disabled: boolean) => void;
+} {
+ function initializeValue(
+ input: InputInfo,
+ value?: InputValue | null,
+ ): InputValue {
+ if (input.type === "text") {
+ return value ?? "";
+ } else if (input.type === "bool") {
+ return value ?? false;
+ } else if (input.type === "select") {
+ return value ?? input.options[0].value;
+ }
+ throw new Error("Unknown input type");
+ }
+
+ function initialize(generalInitialization: GeneralInitialization): State {
+ const initialization: Initialization = Array.isArray(generalInitialization)
+ ? { scheme: { inputs: generalInitialization } }
+ : "scheme" in generalInitialization
+ ? generalInitialization
+ : { scheme: generalInitialization };
+
+ const { scheme, dataInit } = initialization;
+ const { inputs, validator } = scheme;
+ const keys = inputs.map((input) => input.key);
+
+ if (process.env.NODE_ENV === "development") {
+ const checkKeys = (dict: Record<string, unknown> | undefined) => {
+ if (dict != null) {
+ for (const key of Object.keys(dict)) {
+ if (!keys.includes(key)) {
+ console.warn("");
+ }
+ }
+ }
+ };
+
+ checkKeys(dataInit?.values);
+ checkKeys(dataInit?.errors ?? {});
+ checkKeys(dataInit?.disabled);
+ checkKeys(dataInit?.dirties);
+ }
+
+ function clean<V>(
+ dict: Record<string, V> | null | undefined,
+ ): Record<string, NonNullable<V>> {
+ return dict != null ? cleanObject(dict) : {};
+ }
+
+ const values: InputValueDict = {};
+ const disabled: InputDisabledDict = clean(dataInit?.disabled);
+ const dirties: InputDirtyDict = clean(dataInit?.dirties);
+ const isErrorSet = dataInit?.errors != null;
+ let errors: InputErrorDict = clean(dataInit?.errors);
+
+ for (let i = 0; i < inputs.length; i++) {
+ const input = inputs[i];
+ const { key } = input;
+
+ values[key] = initializeValue(input, dataInit?.values?.[key]);
+ }
+
+ if (isErrorSet) {
+ if (process.env.NODE_ENV === "development") {
+ console.log(
+ "You explicitly set errors (not undefined) in initializer, so validator won't run.",
+ );
+ }
+ } else {
+ errors = validate(validator, values, inputs);
+ }
+
+ return {
+ scheme,
+ data: {
+ values,
+ errors,
+ disabled,
+ dirties,
+ },
+ };
+ }
+
+ const { init } = options;
+ const initializer = typeof init === "function" ? init : () => init;
+
+ const [state, setState] = useState<State>(() => initialize(initializer()));
+
+ const { scheme, data } = state;
+ const { validator } = scheme;
+
+ function createAllBooleanDict(value: boolean): Record<string, boolean> {
+ const result: InputDirtyDict = {};
+ for (const key of scheme.inputs.map((input) => input.key)) {
+ result[key] = value;
+ }
+ return result;
+ }
+
+ const createAllDirties = () => createAllBooleanDict(true);
+
+ const componentInputs: Input[] = [];
+
+ for (let i = 0; i < scheme.inputs.length; i++) {
+ const input = scheme.inputs[i];
+ const value = data.values[input.key];
+ const error = data.errors[input.key];
+ const disabled = data.disabled[input.key] ?? false;
+ const dirty = data.dirties[input.key] ?? false;
+ const componentInput: Input = {
+ ...input,
+ value: value as never,
+ disabled,
+ error: dirty ? error : undefined,
+ };
+ componentInputs.push(componentInput);
+ }
+
+ const hasError = Object.keys(data.errors).length > 0;
+ const hasDirty = Object.keys(data.dirties).some((key) => data.dirties[key]);
+
+ return {
+ inputGroupProps: {
+ inputs: componentInputs,
+ onChange: (index, value) => {
+ const input = scheme.inputs[index];
+ const { key } = input;
+ const newValues = { ...data.values, [key]: value };
+ const newDirties = { ...data.dirties, [key]: true };
+ const newErrors = validate(validator, newValues, scheme.inputs);
+ setState({
+ scheme,
+ data: {
+ ...data,
+ values: newValues,
+ errors: newErrors,
+ dirties: newDirties,
+ },
+ });
+ },
+ },
+ hasError,
+ hasErrorAndDirty: hasError && hasDirty,
+ confirm() {
+ const newDirties = createAllDirties();
+ const newErrors = validate(validator, data.values, scheme.inputs);
+
+ setState({
+ scheme,
+ data: {
+ ...data,
+ dirties: newDirties,
+ errors: newErrors,
+ },
+ });
+
+ if (Object.keys(newErrors).length !== 0) {
+ return {
+ type: "error",
+ errors: newErrors,
+ };
+ } else {
+ return {
+ type: "ok",
+ values: data.values as InputConfirmValueDict,
+ };
+ }
+ },
+ setAllDisabled(disabled: boolean) {
+ setState({
+ scheme,
+ data: {
+ ...data,
+ disabled: createAllBooleanDict(disabled),
+ },
+ });
+ },
+ };
+}
+
+export function InputGroup({
+ color,
+ inputs,
+ onChange,
+ containerRef,
+ containerClassName,
+}: InputGroupProps) {
+ const c = useC();
+
+ const id = useId();
+
+ return (
+ <div
+ ref={containerRef}
+ className={classNames(
+ "cru-input-group",
+ `cru-clickable-${color ?? "primary"}`,
+ containerClassName,
+ )}
+ >
+ {inputs.map((item, index) => {
+ const { key, type, value, label, error, helper, disabled } = item;
+
+ const getContainerClassName = (
+ ...additionalClassNames: classNames.ArgumentArray
+ ) =>
+ classNames(
+ `cru-input-container cru-input-type-${type}`,
+ error && "error",
+ ...additionalClassNames,
+ );
+
+ const changeValue = (value: InputValue) => {
+ onChange(index, value);
+ };
+
+ const inputId = `${id}-${key}`;
+
+ if (type === "text") {
+ const { password } = item;
+ return (
+ <div
+ key={key}
+ className={getContainerClassName(password && "password")}
+ >
+ {label && (
+ <label className="cru-input-label" htmlFor={inputId}>
+ {c(label)}
+ </label>
+ )}
+ <input
+ id={inputId}
+ type={password ? "password" : "text"}
+ value={value}
+ onChange={(event) => {
+ const v = event.target.value;
+ changeValue(v);
+ }}
+ disabled={disabled}
+ />
+ {error && <div className="cru-input-error">{c(error)}</div>}
+ {helper && <div className="cru-input-helper">{c(helper)}</div>}
+ </div>
+ );
+ } else if (type === "bool") {
+ return (
+ <div key={key} className={getContainerClassName()}>
+ <input
+ id={inputId}
+ type="checkbox"
+ checked={value}
+ onChange={(event) => {
+ const v = event.currentTarget.checked;
+ changeValue(v);
+ }}
+ disabled={disabled}
+ />
+ <label className="cru-input-label-inline" htmlFor={inputId}>
+ {c(label)}
+ </label>
+ {error && <div className="cru-input-error">{c(error)}</div>}
+ {helper && <div className="cru-input-helper">{c(helper)}</div>}
+ </div>
+ );
+ } else if (type === "select") {
+ return (
+ <div key={key} className={getContainerClassName()}>
+ <label className="cru-input-label" htmlFor={inputId}>
+ {c(label)}
+ </label>
+ <select
+ id={inputId}
+ value={value}
+ onChange={(event) => {
+ const e = event.target.value;
+ changeValue(e);
+ }}
+ disabled={disabled}
+ >
+ {item.options.map((option) => {
+ return (
+ <option value={option.value} key={option.value}>
+ {option.icon}
+ {c(option.label)}
+ </option>
+ );
+ })}
+ </select>
+ </div>
+ );
+ }
+ })}
+ </div>
+ );
+}
diff --git a/FrontEnd/src/components/input/index.ts b/FrontEnd/src/components/input/index.ts
new file mode 100644
index 00000000..ca183089
--- /dev/null
+++ b/FrontEnd/src/components/input/index.ts
@@ -0,0 +1,11 @@
+export { useInputs, InputGroup } from "./InputGroup";
+
+export type {
+ InputValueDict,
+ InputErrorDict,
+ InputDirtyDict,
+ InputDisabledDict,
+ InputConfirmValueDict,
+ Validator,
+ Initializer,
+} from "./InputGroup";
diff --git a/FrontEnd/src/components/list/ListContainer.css b/FrontEnd/src/components/list/ListContainer.css
new file mode 100644
index 00000000..53781834
--- /dev/null
+++ b/FrontEnd/src/components/list/ListContainer.css
@@ -0,0 +1,4 @@
+.cru-list-container {
+ border: 1px solid var(--cru-clickable-primary-normal-color);
+ border-radius: 5px;
+}
diff --git a/FrontEnd/src/components/list/ListContainer.tsx b/FrontEnd/src/components/list/ListContainer.tsx
new file mode 100644
index 00000000..aa00d12c
--- /dev/null
+++ b/FrontEnd/src/components/list/ListContainer.tsx
@@ -0,0 +1,23 @@
+import { ComponentPropsWithoutRef, forwardRef, Ref } from "react";
+import classNames from "classnames";
+
+import "./ListContainer.css"
+
+function _ListContainer(
+ { className, children, ...otherProps }: ComponentPropsWithoutRef<"div">,
+ ref: Ref<HTMLDivElement>,
+) {
+ return (
+ <div
+ ref={ref}
+ className={classNames("cru-list-container", className)}
+ {...otherProps}
+ >
+ {children}
+ </div>
+ );
+}
+
+const ListContainer = forwardRef(_ListContainer);
+
+export default ListContainer;
diff --git a/FrontEnd/src/components/list/ListItemContainer.css b/FrontEnd/src/components/list/ListItemContainer.css
new file mode 100644
index 00000000..8d7afa9f
--- /dev/null
+++ b/FrontEnd/src/components/list/ListItemContainer.css
@@ -0,0 +1,3 @@
+.cru-list-item-container {
+ border: 1px solid var(--cru-clickable-primary-normal-color);
+}
diff --git a/FrontEnd/src/components/list/ListItemContainer.tsx b/FrontEnd/src/components/list/ListItemContainer.tsx
new file mode 100644
index 00000000..315cbd6e
--- /dev/null
+++ b/FrontEnd/src/components/list/ListItemContainer.tsx
@@ -0,0 +1,23 @@
+import { ComponentPropsWithoutRef, forwardRef, Ref } from "react";
+import classNames from "classnames";
+
+import "./ListItemContainer.css";
+
+function _ListItemContainer(
+ { className, children, ...otherProps }: ComponentPropsWithoutRef<"div">,
+ ref: Ref<HTMLDivElement>,
+) {
+ return (
+ <div
+ ref={ref}
+ className={classNames("cru-list-item-container", className)}
+ {...otherProps}
+ >
+ {children}
+ </div>
+ );
+}
+
+const ListItemContainer = forwardRef(_ListItemContainer);
+
+export default ListItemContainer;
diff --git a/FrontEnd/src/components/list/index.ts b/FrontEnd/src/components/list/index.ts
new file mode 100644
index 00000000..e183f7da
--- /dev/null
+++ b/FrontEnd/src/components/list/index.ts
@@ -0,0 +1,4 @@
+import ListContainer from "./ListContainer";
+import ListItemContainer from "./ListItemContainer";
+
+export { ListContainer, ListItemContainer };
diff --git a/FrontEnd/src/components/menu/Menu.css b/FrontEnd/src/components/menu/Menu.css
new file mode 100644
index 00000000..75734533
--- /dev/null
+++ b/FrontEnd/src/components/menu/Menu.css
@@ -0,0 +1,36 @@
+.cru-menu {
+ min-width: 200px;
+}
+
+.cru-menu-item {
+ display: block;
+ font-size: 1em;
+ width: 100%;
+ padding: 0.5em 1.5em;
+ transition: all 0.5s;
+ color: var(--cru-clickable-normal-color);
+ background-color: var(--cru-clickable-grayscale-normal-color);
+ border: none;
+ cursor: pointer;
+}
+
+.cru-menu-item:hover {
+ background-color: var(--cru-clickable-grayscale-hover-color);
+}
+
+.cru-menu-item:focus {
+ background-color: var(--cru-clickable-grayscale-focus-color);
+}
+
+.cru-menu-item:active {
+ background-color: var(--cru-clickable-grayscale-active-color);
+}
+
+.cru-menu-item-icon {
+ margin-right: 1em;
+}
+
+.cru-menu-divider {
+ border-width: 0;
+ border-top: 1px solid var(--cru-primary-color);
+} \ No newline at end of file
diff --git a/FrontEnd/src/components/menu/Menu.tsx b/FrontEnd/src/components/menu/Menu.tsx
new file mode 100644
index 00000000..e8099c76
--- /dev/null
+++ b/FrontEnd/src/components/menu/Menu.tsx
@@ -0,0 +1,62 @@
+import { CSSProperties } from "react";
+import classNames from "classnames";
+
+import { useC, Text, ThemeColor } from "../common";
+
+import "./Menu.css";
+import Icon from "../Icon";
+
+export type MenuItem =
+ | {
+ type: "divider";
+ }
+ | {
+ type: "button";
+ text: Text;
+ icon?: string;
+ color?: ThemeColor;
+ onClick: () => void;
+ };
+
+export type MenuItems = MenuItem[];
+
+export type MenuProps = {
+ items: MenuItems;
+ onItemClicked?: () => void;
+ className?: string;
+ style?: CSSProperties;
+};
+
+export default function Menu({
+ items,
+ onItemClicked,
+ className,
+ style,
+}: MenuProps) {
+ const c = useC();
+
+ return (
+ <div className={classNames("cru-menu", className)} style={style}>
+ {items.map((item, index) => {
+ if (item.type === "divider") {
+ return <hr key={index} className="cru-menu-divider" />;
+ } else {
+ const { text, color, icon, onClick } = item;
+ return (
+ <button
+ key={index}
+ className={`cru-menu-item cru-clickable-${color ?? "primary"}`}
+ onClick={() => {
+ onClick();
+ onItemClicked?.();
+ }}
+ >
+ {icon != null && <Icon color={color} icon={icon} />}
+ {c(text)}
+ </button>
+ );
+ }
+ })}
+ </div>
+ );
+}
diff --git a/FrontEnd/src/components/menu/PopupMenu.css b/FrontEnd/src/components/menu/PopupMenu.css
new file mode 100644
index 00000000..149e0699
--- /dev/null
+++ b/FrontEnd/src/components/menu/PopupMenu.css
@@ -0,0 +1,7 @@
+.cru-popup-menu-menu-container {
+ z-index: 1040;
+ border-radius: 3px;
+ border: var(--cru-clickable-normal-color) 1.5px solid;
+ background-color: var(--cru-background-color);
+ overflow: hidden;
+}
diff --git a/FrontEnd/src/components/menu/PopupMenu.tsx b/FrontEnd/src/components/menu/PopupMenu.tsx
new file mode 100644
index 00000000..23a67f79
--- /dev/null
+++ b/FrontEnd/src/components/menu/PopupMenu.tsx
@@ -0,0 +1,73 @@
+import { useState, CSSProperties, ReactNode } from "react";
+import classNames from "classnames";
+import { createPortal } from "react-dom";
+import { usePopper } from "react-popper";
+
+import { useClickOutside } from "~src/utilities/hooks";
+
+import Menu, { MenuItems } from "./Menu";
+
+import { ThemeColor } from "../common";
+
+import "./PopupMenu.css";
+
+export interface PopupMenuProps {
+ color?: ThemeColor;
+ items: MenuItems;
+ children?: ReactNode;
+ containerClassName?: string;
+ containerStyle?: CSSProperties;
+}
+
+export default function PopupMenu({
+ color,
+ items,
+ children,
+ containerClassName,
+ containerStyle,
+}: PopupMenuProps) {
+ const [show, setShow] = useState<boolean>(false);
+
+ const [referenceElement, setReferenceElement] =
+ useState<HTMLDivElement | null>(null);
+ const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(
+ null,
+ );
+ const { styles, attributes } = usePopper(referenceElement, popperElement);
+
+ useClickOutside(popperElement, () => setShow(false), true);
+
+ return (
+ <div
+ ref={setReferenceElement}
+ className={classNames(
+ "cru-popup-menu-trigger-container",
+ containerClassName,
+ )}
+ style={containerStyle}
+ onClick={() => setShow(true)}
+ >
+ {children}
+ {show &&
+ createPortal(
+ <div
+ ref={setPopperElement}
+ className={`cru-popup-menu-menu-container cru-clickable-${
+ color ?? "primary"
+ }`}
+ 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")!,
+ )}
+ </div>
+ );
+}
diff --git a/FrontEnd/src/components/tab/TabPages.tsx b/FrontEnd/src/components/tab/TabPages.tsx
new file mode 100644
index 00000000..6a5f4469
--- /dev/null
+++ b/FrontEnd/src/components/tab/TabPages.tsx
@@ -0,0 +1,71 @@
+import * as React from "react";
+
+import { I18nText, UiLogicError } from "~src/common";
+
+import Tabs from "./Tabs";
+
+export interface TabPage {
+ name: string;
+ text: I18nText;
+ page: React.ReactNode;
+}
+
+export interface TabPagesProps {
+ pages: TabPage[];
+ actions?: React.ReactNode;
+ dense?: boolean;
+ className?: string;
+ style?: React.CSSProperties;
+ navClassName?: string;
+ navStyle?: React.CSSProperties;
+ pageContainerClassName?: string;
+ pageContainerStyle?: React.CSSProperties;
+}
+
+const TabPages: React.FC<TabPagesProps> = ({
+ pages,
+ actions,
+ dense,
+ className,
+ style,
+ navClassName,
+ navStyle,
+ pageContainerClassName,
+ pageContainerStyle,
+}) => {
+ if (pages.length === 0) {
+ throw new UiLogicError("Page list can't be empty.");
+ }
+
+ const [tab, setTab] = React.useState<string>(pages[0].name);
+
+ const currentPage = pages.find((p) => p.name === tab);
+
+ if (currentPage == null) {
+ throw new UiLogicError("Current tab value is bad.");
+ }
+
+ return (
+ <div className={className} style={style}>
+ <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>
+ </div>
+ );
+};
+
+export default TabPages;
diff --git a/FrontEnd/src/components/tab/Tabs.css b/FrontEnd/src/components/tab/Tabs.css
new file mode 100644
index 00000000..395d16a7
--- /dev/null
+++ b/FrontEnd/src/components/tab/Tabs.css
@@ -0,0 +1,33 @@
+.cru-nav {
+ border-bottom: var(--cru-primary-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.active {
+ color: var(--cru-primary-t-color);
+ background-color: var(--cru-primary-color);
+ border-color: var(--cru-primary-color);
+}
+
+.cru-nav-action-area {
+ margin-left: auto;
+}
diff --git a/FrontEnd/src/components/tab/Tabs.tsx b/FrontEnd/src/components/tab/Tabs.tsx
new file mode 100644
index 00000000..dc8d9c01
--- /dev/null
+++ b/FrontEnd/src/components/tab/Tabs.tsx
@@ -0,0 +1,62 @@
+import * as React from "react";
+import { Link } from "react-router-dom";
+import { useTranslation } from "react-i18next";
+import classnames from "classnames";
+
+import { convertI18nText, I18nText } from "~src/common";
+
+import "./Tabs.css";
+
+export interface Tab {
+ name: string;
+ text: I18nText;
+ link?: string;
+ onClick?: () => void;
+}
+
+export interface TabsProps {
+ activeTabName?: string;
+ actions?: React.ReactNode;
+ dense?: boolean;
+ tabs: Tab[];
+ className?: string;
+ style?: React.CSSProperties;
+}
+
+export default function Tabs(props: TabsProps): React.ReactElement | null {
+ const { tabs, activeTabName, className, style, dense, actions } = props;
+
+ const { t } = useTranslation();
+
+ return (
+ <div
+ className={classnames("cru-nav", dense && "dense", className)}
+ style={style}
+ >
+ {tabs.map((tab) => {
+ const active = activeTabName === tab.name;
+ const className = classnames("cru-nav-item", active && "active");
+
+ if (tab.link != null) {
+ return (
+ <Link
+ key={tab.name}
+ to={tab.link}
+ onClick={tab.onClick}
+ className={className}
+ >
+ {convertI18nText(tab.text, t)}
+ </Link>
+ );
+ } else {
+ return (
+ <span key={tab.name} onClick={tab.onClick} className={className}>
+ {convertI18nText(tab.text, t)}
+ </span>
+ );
+ }
+ })}
+ <div className="cru-nav-action-area">{actions}</div>
+ </div>
+ );
+}
diff --git a/FrontEnd/src/components/theme-color.css b/FrontEnd/src/components/theme-color.css
new file mode 100644
index 00000000..24a7e267
--- /dev/null
+++ b/FrontEnd/src/components/theme-color.css
@@ -0,0 +1,173 @@
+/* Generated by theme-generator.ts */
+
+:root {
+ --cru-primary-color: hsl(210 100% 40%);
+ --cru-primary-1-color: hsl(210 100% 37%);
+ --cru-primary-2-color: hsl(210 100% 34%);
+ --cru-primary-on-color: hsl(210 100% 100%);
+ --cru-primary-container-color: hsl(210 100% 90%);
+ --cru-primary-container-1-color: hsl(210 100% 80%);
+ --cru-primary-container-2-color: hsl(210 100% 70%);
+ --cru-primary-on-container-color: hsl(210 100% 10%);
+ --cru-secondary-color: hsl(40 100% 40%);
+ --cru-secondary-1-color: hsl(40 100% 37%);
+ --cru-secondary-2-color: hsl(40 100% 34%);
+ --cru-secondary-on-color: hsl(40 100% 100%);
+ --cru-secondary-container-color: hsl(40 100% 90%);
+ --cru-secondary-container-1-color: hsl(40 100% 80%);
+ --cru-secondary-container-2-color: hsl(40 100% 70%);
+ --cru-secondary-on-container-color: hsl(40 100% 10%);
+ --cru-tertiary-color: hsl(160 100% 40%);
+ --cru-tertiary-1-color: hsl(160 100% 37%);
+ --cru-tertiary-2-color: hsl(160 100% 34%);
+ --cru-tertiary-on-color: hsl(160 100% 100%);
+ --cru-tertiary-container-color: hsl(160 100% 90%);
+ --cru-tertiary-container-1-color: hsl(160 100% 80%);
+ --cru-tertiary-container-2-color: hsl(160 100% 70%);
+ --cru-tertiary-on-container-color: hsl(160 100% 10%);
+ --cru-danger-color: hsl(0 100% 40%);
+ --cru-danger-1-color: hsl(0 100% 37%);
+ --cru-danger-2-color: hsl(0 100% 34%);
+ --cru-danger-on-color: hsl(0 100% 100%);
+ --cru-danger-container-color: hsl(0 100% 90%);
+ --cru-danger-container-1-color: hsl(0 100% 80%);
+ --cru-danger-container-2-color: hsl(0 100% 70%);
+ --cru-danger-on-container-color: hsl(0 100% 10%);
+ --cru-success-color: hsl(120 60% 40%);
+ --cru-success-1-color: hsl(120 60% 37%);
+ --cru-success-2-color: hsl(120 60% 34%);
+ --cru-success-on-color: hsl(120 60% 100%);
+ --cru-success-container-color: hsl(120 60% 90%);
+ --cru-success-container-1-color: hsl(120 60% 80%);
+ --cru-success-container-2-color: hsl(120 60% 70%);
+ --cru-success-on-container-color: hsl(120 60% 10%);
+ --cru-surface-dim-color: hsl(0 0% 87%);
+ --cru-surface-color: hsl(0 0% 98%);
+ --cru-surface-1-color: hsl(0 0% 90%);
+ --cru-surface-2-color: hsl(0 0% 82%);
+ --cru-surface-bright-color: hsl(0 0% 98%);
+ --cru-surface-container-lowest-color: hsl(0 0% 100%);
+ --cru-surface-container-low-color: hsl(0 0% 96%);
+ --cru-surface-container-color: hsl(0 0% 94%);
+ --cru-surface-container-high-color: hsl(0 0% 92%);
+ --cru-surface-container-highest-color: hsl(0 0% 90%);
+ --cru-surface-on-color: hsl(0 0% 10%);
+ --cru-surface-on-variant-color: hsl(0 0% 30%);
+ --cru-surface-outline-color: hsl(0 0% 50%);
+ --cru-surface-outline-variant-color: hsl(0 0% 80%);
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --cru-primary-color: hsl(210 100% 80%);
+ --cru-primary-1-color: hsl(210 100% 75%);
+ --cru-primary-2-color: hsl(210 100% 68%);
+ --cru-primary-on-color: hsl(210 100% 20%);
+ --cru-primary-container-color: hsl(210 100% 30%);
+ --cru-primary-container-1-color: hsl(210 100% 25%);
+ --cru-primary-container-2-color: hsl(210 100% 20%);
+ --cru-primary-on-container-color: hsl(210 100% 90%);
+ --cru-secondary-color: hsl(40 100% 80%);
+ --cru-secondary-1-color: hsl(40 100% 75%);
+ --cru-secondary-2-color: hsl(40 100% 68%);
+ --cru-secondary-on-color: hsl(40 100% 20%);
+ --cru-secondary-container-color: hsl(40 100% 30%);
+ --cru-secondary-container-1-color: hsl(40 100% 25%);
+ --cru-secondary-container-2-color: hsl(40 100% 20%);
+ --cru-secondary-on-container-color: hsl(40 100% 90%);
+ --cru-tertiary-color: hsl(160 100% 80%);
+ --cru-tertiary-1-color: hsl(160 100% 75%);
+ --cru-tertiary-2-color: hsl(160 100% 68%);
+ --cru-tertiary-on-color: hsl(160 100% 20%);
+ --cru-tertiary-container-color: hsl(160 100% 30%);
+ --cru-tertiary-container-1-color: hsl(160 100% 25%);
+ --cru-tertiary-container-2-color: hsl(160 100% 20%);
+ --cru-tertiary-on-container-color: hsl(160 100% 90%);
+ --cru-danger-color: hsl(0 100% 80%);
+ --cru-danger-1-color: hsl(0 100% 75%);
+ --cru-danger-2-color: hsl(0 100% 68%);
+ --cru-danger-on-color: hsl(0 100% 20%);
+ --cru-danger-container-color: hsl(0 100% 30%);
+ --cru-danger-container-1-color: hsl(0 100% 25%);
+ --cru-danger-container-2-color: hsl(0 100% 20%);
+ --cru-danger-on-container-color: hsl(0 100% 90%);
+ --cru-success-color: hsl(120 60% 80%);
+ --cru-success-1-color: hsl(120 60% 75%);
+ --cru-success-2-color: hsl(120 60% 68%);
+ --cru-success-on-color: hsl(120 60% 20%);
+ --cru-success-container-color: hsl(120 60% 30%);
+ --cru-success-container-1-color: hsl(120 60% 25%);
+ --cru-success-container-2-color: hsl(120 60% 20%);
+ --cru-success-on-container-color: hsl(120 60% 90%);
+ --cru-surface-dim-color: hsl(0 0% 6%);
+ --cru-surface-color: hsl(0 0% 6%);
+ --cru-surface-1-color: hsl(0 0% 25%);
+ --cru-surface-2-color: hsl(0 0% 40%);
+ --cru-surface-bright-color: hsl(0 0% 24%);
+ --cru-surface-container-lowest-color: hsl(0 0% 4%);
+ --cru-surface-container-low-color: hsl(0 0% 10%);
+ --cru-surface-container-color: hsl(0 0% 12%);
+ --cru-surface-container-high-color: hsl(0 0% 17%);
+ --cru-surface-container-highest-color: hsl(0 0% 22%);
+ --cru-surface-on-color: hsl(0 0% 90%);
+ --cru-surface-on-variant-color: hsl(0 0% 80%);
+ --cru-surface-outline-color: hsl(0 0% 60%);
+ --cru-surface-outline-variant-color: hsl(0 0% 30%);
+ }
+}
+
+.cru-primary {
+ --cru-key-color: var(--cru-primary-color);
+ --cru-key-1-color: var(--cru-primary-1-color);
+ --cru-key-2-color: var(--cru-primary-2-color);
+ --cru-key-on-color: var(--cru-primary-on-color);
+ --cru-key-container-color: var(--cru-primary-container-color);
+ --cru-key-container-1-color: var(--cru-primary-container-1-color);
+ --cru-key-container-2-color: var(--cru-primary-container-2-color);
+ --cru-key-on-container-color: var(--cru-primary-on-container-color);
+}
+
+.cru-secondary {
+ --cru-key-color: var(--cru-secondary-color);
+ --cru-key-1-color: var(--cru-secondary-1-color);
+ --cru-key-2-color: var(--cru-secondary-2-color);
+ --cru-key-on-color: var(--cru-secondary-on-color);
+ --cru-key-container-color: var(--cru-secondary-container-color);
+ --cru-key-container-1-color: var(--cru-secondary-container-1-color);
+ --cru-key-container-2-color: var(--cru-secondary-container-2-color);
+ --cru-key-on-container-color: var(--cru-secondary-on-container-color);
+}
+
+.cru-tertiary {
+ --cru-key-color: var(--cru-tertiary-color);
+ --cru-key-1-color: var(--cru-tertiary-1-color);
+ --cru-key-2-color: var(--cru-tertiary-2-color);
+ --cru-key-on-color: var(--cru-tertiary-on-color);
+ --cru-key-container-color: var(--cru-tertiary-container-color);
+ --cru-key-container-1-color: var(--cru-tertiary-container-1-color);
+ --cru-key-container-2-color: var(--cru-tertiary-container-2-color);
+ --cru-key-on-container-color: var(--cru-tertiary-on-container-color);
+}
+
+.cru-danger {
+ --cru-key-color: var(--cru-danger-color);
+ --cru-key-1-color: var(--cru-danger-1-color);
+ --cru-key-2-color: var(--cru-danger-2-color);
+ --cru-key-on-color: var(--cru-danger-on-color);
+ --cru-key-container-color: var(--cru-danger-container-color);
+ --cru-key-container-1-color: var(--cru-danger-container-1-color);
+ --cru-key-container-2-color: var(--cru-danger-container-2-color);
+ --cru-key-on-container-color: var(--cru-danger-on-container-color);
+}
+
+.cru-success {
+ --cru-key-color: var(--cru-success-color);
+ --cru-key-1-color: var(--cru-success-1-color);
+ --cru-key-2-color: var(--cru-success-2-color);
+ --cru-key-on-color: var(--cru-success-on-color);
+ --cru-key-container-color: var(--cru-success-container-color);
+ --cru-key-container-1-color: var(--cru-success-container-1-color);
+ --cru-key-container-2-color: var(--cru-success-container-2-color);
+ --cru-key-on-container-color: var(--cru-success-on-container-color);
+}
+
diff --git a/FrontEnd/src/components/theme.css b/FrontEnd/src/components/theme.css
new file mode 100644
index 00000000..6ceb369f
--- /dev/null
+++ b/FrontEnd/src/components/theme.css
@@ -0,0 +1,146 @@
+@import "./theme-color.css";
+
+:root {
+ --cru-default-font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
+ --cru-page-padding: 1em 2em;
+
+ --cru-border-radius: 4px;
+ --cru-card-border-radius: 4px;
+}
+
+/* theme colors */
+:root {
+ --cru-primary-color: hsl(210, 100%, 50%);
+ --cru-secondary-color: hsl(30, 100%, 50%);
+ --cru-create-color: hsl(120, 100%, 25%);
+ --cru-danger-color: hsl(0, 100%, 50%);
+}
+
+/* common colors */
+:root {
+ --cru-background-color: hsl(0, 0%, 100%);
+ --cru-container-background-color: hsl(0, 0%, 97%);
+ --cru-text-primary-color: hsl(0, 0%, 0%);
+ --cru-text-secondary-color: hsl(0, 0%, 38%);
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --cru-background-color: hsl(0, 0%, 0%);
+ --cru-container-background-color: hsl(0, 0%, 2%);
+ --cru-text-primary-color: hsl(0, 0%, 100%);
+ --cru-text-secondary-color: hsl(0, 0%, 85%);
+ }
+}
+
+:root {
+ --cru-body-background-color: var(--cru-background-color);
+}
+
+/* clickable color */
+:root {
+ --cru-clickable-primary-normal-color: var(--cru-primary-color);
+ --cru-clickable-primary-hover-color: hsl(210, 100%, 60%);
+ --cru-clickable-primary-focus-color: hsl(210, 100%, 60%);
+ --cru-clickable-primary-active-color: hsl(210, 100%, 70%);
+ --cru-clickable-secondary-normal-color: var(--cru-secondary-color);
+ --cru-clickable-secondary-hover-color: hsl(30, 100%, 60%);
+ --cru-clickable-secondary-focus-color: hsl(30, 100%, 60%);
+ --cru-clickable-secondary-active-color: hsl(30, 100%, 70%);
+ --cru-clickable-create-normal-color: var(--cru-create-color);
+ --cru-clickable-create-hover-color: hsl(120, 100%, 35%);
+ --cru-clickable-create-focus-color: hsl(120, 100%, 35%);
+ --cru-clickable-create-active-color: hsl(120, 100%, 35%);
+ --cru-clickable-danger-normal-color: var(--cru-danger-color);
+ --cru-clickable-danger-hover-color: hsl(0, 100%, 60%);
+ --cru-clickable-danger-focus-color: hsl(0, 100%, 60%);
+ --cru-clickable-danger-active-color: hsl(0, 100%, 70%);
+ --cru-clickable-grayscale-normal-color: hsl(0, 0%, 100%);
+ --cru-clickable-grayscale-hover-color: hsl(0, 0%, 92%);
+ --cru-clickable-grayscale-focus-color: hsl(0, 0%, 92%);
+ --cru-clickable-grayscale-active-color: hsl(0, 0%, 88%);
+ --cru-clickable-disabled-color: hsl(0, 0%, 50%);
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --cru-clickable-grayscale-normal-color: hsl(0, 0%, 0%);
+ --cru-clickable-grayscale-hover-color: hsl(0, 0%, 10%);
+ --cru-clickable-grayscale-focus-color: hsl(0, 0%, 10%);
+ --cru-clickable-grayscale-active-color: hsl(0, 0%, 20%);
+ }
+}
+
+.cru-clickable-primary {
+ --cru-clickable-normal-color: var(--cru-clickable-primary-normal-color);
+ --cru-clickable-hover-color: var(--cru-clickable-primary-hover-color);
+ --cru-clickable-focus-color: var(--cru-clickable-primary-focus-color);
+ --cru-clickable-active-color: var(--cru-clickable-primary-active-color);
+}
+
+.cru-clickable-secondary {
+ --cru-clickable-normal-color: var(--cru-clickable-secondary-normal-color);
+ --cru-clickable-hover-color: var(--cru-clickable-secondary-hover-color);
+ --cru-clickable-focus-color: var(--cru-clickable-secondary-focus-color);
+ --cru-clickable-active-color: var(--cru-clickable-secondary-active-color);
+}
+
+.cru-clickable-create {
+ --cru-clickable-normal-color: var(--cru-clickable-create-normal-color);
+ --cru-clickable-hover-color: var(--cru-clickable-create-hover-color);
+ --cru-clickable-focus-color: var(--cru-clickable-create-focus-color);
+ --cru-clickable-active-color: var(--cru-clickable-create-active-color);
+}
+
+.cru-clickable-danger {
+ --cru-clickable-normal-color: var(--cru-clickable-danger-normal-color);
+ --cru-clickable-hover-color: var(--cru-clickable-danger-hover-color);
+ --cru-clickable-focus-color: var(--cru-clickable-danger-focus-color);
+ --cru-clickable-active-color: var(--cru-clickable-danger-active-color);
+}
+
+.cru-clickable-grayscale {
+ --cru-clickable-normal-color: var(--cru-clickable-grayscale-normal-color);
+ --cru-clickable-hover-color: var(--cru-clickable-grayscale-hover-color);
+ --cru-clickable-focus-color: var(--cru-clickable-grayscale-focus-color);
+ --cru-clickable-active-color: var(--cru-clickable-grayscale-active-color);
+}
+
+/* button colors */
+:root {
+ /* push button colors */
+ --cru-push-button-text-color: #ffffff;
+ --cru-push-button-disabled-text-color: hsl(0, 0%, 80%);
+}
+
+/* Card colors */
+:root {
+ --cru-card-background-primary-color: hsl(210, 100%, 50%);
+ --cru-card-border-primary-color: hsl(210, 100%, 50%);
+ --cru-card-background-secondary-color: hsl(30, 100%, 50%);
+ --cru-card-border-secondary-color: hsl(30, 100%, 50%);
+ --cru-card-background-create-color: hsl(120, 100%, 25%);
+ --cru-card-border-create-color: hsl(120, 100%, 25%);
+ --cru-card-background-danger-color: hsl(0, 100%, 50%);
+ --cru-card-border-danger-color: hsl(0, 100%, 50%);
+}
+
+.cru-card-primary {
+ --cru-card-background-color: var(--cru-card-background-primary-color);
+ --cru-card-border-color: var(--cru-card-border-primary-color)
+}
+
+.cru-card-secondary {
+ --cru-card-background-color: var(--cru-card-background-secondary-color);
+ --cru-card-border-color: var(--cru-card-border-secondary-color)
+}
+
+.cru-card-create {
+ --cru-card-background-color: var(--cru-card-background-create-color);
+ --cru-card-border-color: var(--cru-card-border-create-color)
+}
+
+.cru-card-danger {
+ --cru-card-background-color: var(--cru-card-background-danger-color);
+ --cru-card-border-color: var(--cru-card-border-danger-color)
+} \ No newline at end of file
diff --git a/FrontEnd/src/components/user/UserAvatar.tsx b/FrontEnd/src/components/user/UserAvatar.tsx
new file mode 100644
index 00000000..8671f2d8
--- /dev/null
+++ b/FrontEnd/src/components/user/UserAvatar.tsx
@@ -0,0 +1,22 @@
+import { Ref, ComponentPropsWithoutRef } from "react";
+
+import { getHttpUserClient } from "~src/http/user";
+
+export interface UserAvatarProps extends ComponentPropsWithoutRef<"img"> {
+ username: string;
+ imgRef?: Ref<HTMLImageElement> | null;
+}
+
+export default function UserAvatar({
+ username,
+ imgRef,
+ ...otherProps
+}: UserAvatarProps) {
+ return (
+ <img
+ ref={imgRef}
+ src={getHttpUserClient().generateAvatarUrl(username)}
+ {...otherProps}
+ />
+ );
+}