aboutsummaryrefslogtreecommitdiff
path: root/FrontEnd/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'FrontEnd/src/components')
-rw-r--r--FrontEnd/src/components/AppBar.css96
-rw-r--r--FrontEnd/src/components/AppBar.tsx106
-rw-r--r--FrontEnd/src/components/BlobImage.tsx41
-rw-r--r--FrontEnd/src/components/Card.css20
-rw-r--r--FrontEnd/src/components/Card.tsx39
-rw-r--r--FrontEnd/src/components/Icon.css4
-rw-r--r--FrontEnd/src/components/Icon.tsx28
-rw-r--r--FrontEnd/src/components/ImageCropper.css34
-rw-r--r--FrontEnd/src/components/ImageCropper.tsx323
-rw-r--r--FrontEnd/src/components/LoadFailReload.tsx37
-rw-r--r--FrontEnd/src/components/Page.css8
-rw-r--r--FrontEnd/src/components/Page.tsx17
-rw-r--r--FrontEnd/src/components/SearchInput.css8
-rw-r--r--FrontEnd/src/components/SearchInput.tsx50
-rw-r--r--FrontEnd/src/components/Skeleton.css20
-rw-r--r--FrontEnd/src/components/Skeleton.tsx22
-rw-r--r--FrontEnd/src/components/Spinner.css13
-rw-r--r--FrontEnd/src/components/Spinner.tsx46
-rw-r--r--FrontEnd/src/components/TimelineLogo.tsx27
-rw-r--r--FrontEnd/src/components/alert/AlertHost.tsx82
-rw-r--r--FrontEnd/src/components/alert/AlertService.ts114
-rw-r--r--FrontEnd/src/components/alert/alert.css21
-rw-r--r--FrontEnd/src/components/alert/index.ts8
-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.tsx146
-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.tsx39
-rw-r--r--FrontEnd/src/components/button/index.tsx15
-rw-r--r--FrontEnd/src/components/common.ts22
-rw-r--r--FrontEnd/src/components/dialog/ConfirmDialog.tsx54
-rw-r--r--FrontEnd/src/components/dialog/Dialog.css39
-rw-r--r--FrontEnd/src/components/dialog/Dialog.tsx55
-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/DialogProvider.tsx95
-rw-r--r--FrontEnd/src/components/dialog/FullPageDialog.css30
-rw-r--r--FrontEnd/src/components/dialog/FullPageDialog.tsx52
-rw-r--r--FrontEnd/src/components/dialog/OperationDialog.css4
-rw-r--r--FrontEnd/src/components/dialog/OperationDialog.tsx221
-rw-r--r--FrontEnd/src/components/dialog/index.tsx12
-rw-r--r--FrontEnd/src/components/hooks/index.ts5
-rw-r--r--FrontEnd/src/components/hooks/responsive.ts7
-rw-r--r--FrontEnd/src/components/hooks/useAutoUnsubscribePromise.ts24
-rw-r--r--FrontEnd/src/components/hooks/useClickOutside.ts38
-rw-r--r--FrontEnd/src/components/hooks/useScrollToBottom.ts44
-rw-r--r--FrontEnd/src/components/hooks/useWindowLeave.ts22
-rw-r--r--FrontEnd/src/components/index.css49
-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.css7
-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.tsx72
-rw-r--r--FrontEnd/src/components/tab/TabBar.css32
-rw-r--r--FrontEnd/src/components/tab/TabBar.tsx69
-rw-r--r--FrontEnd/src/components/tab/TabPages.css3
-rw-r--r--FrontEnd/src/components/tab/TabPages.tsx61
-rw-r--r--FrontEnd/src/components/tab/index.ts2
-rw-r--r--FrontEnd/src/components/theme.css201
-rw-r--r--FrontEnd/src/components/user/UserAvatar.tsx22
74 files changed, 3719 insertions, 0 deletions
diff --git a/FrontEnd/src/components/AppBar.css b/FrontEnd/src/components/AppBar.css
new file mode 100644
index 00000000..38497478
--- /dev/null
+++ b/FrontEnd/src/components/AppBar.css
@@ -0,0 +1,96 @@
+.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 > * {
+ background-color: var(--cru-primary-color);
+}
+
+.app-bar .app-bar-brand {
+ display: flex;
+ align-items: center;
+}
+
+.app-bar .app-bar-brand-icon {
+ height: 2em;
+}
+
+.app-bar .app-bar-space {
+ flex-grow: 1;
+}
+
+.app-bar .app-bar-user-area {
+ display: flex;
+}
+
+.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;
+ left: 0;
+ right: 0;
+ top: 100%;
+ translate: 0 -100%;
+ transition: transform 0.5s;
+}
+
+.app-bar.mobile a {
+ height: 56px;
+}
+
+.app-bar.mobile.expand .app-bar-link-area {
+ transform: translateY(100%);
+}
+
+.app-bar .toggler {
+ font-size: 2em;
+ padding-right: 0.5em;
+}
+
diff --git a/FrontEnd/src/components/AppBar.tsx b/FrontEnd/src/components/AppBar.tsx
new file mode 100644
index 00000000..d40c8105
--- /dev/null
+++ b/FrontEnd/src/components/AppBar.tsx
@@ -0,0 +1,106 @@
+import { useState } from "react";
+import classnames from "classnames";
+import { Link, NavLink } from "react-router-dom";
+
+import { useUser } from "~src/services/user";
+
+import { I18nText, useC } from "./common";
+import { useMobile } from "./hooks";
+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 || "expand",
+ )}
+ >
+ <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-space" />
+
+ <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"
+ color="light"
+ 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..047a13b4
--- /dev/null
+++ b/FrontEnd/src/components/BlobImage.tsx
@@ -0,0 +1,41 @@
+import {
+ useState,
+ useEffect,
+ useMemo,
+ Ref,
+ ComponentPropsWithoutRef,
+} from "react";
+
+type BlobImageProps = Omit<ComponentPropsWithoutRef<"img">, "src"> & {
+ imgRef?: Ref<HTMLImageElement>;
+ src?: Blob | string | null;
+ keyBySrc?: boolean;
+};
+
+export default function BlobImage(props: BlobImageProps) {
+ const { imgRef, src, keyBySrc, ...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]);
+
+ const key = useMemo(() => {
+ if (keyBySrc) {
+ return url == null ? undefined : btoa(url);
+ } else {
+ return undefined;
+ }
+ }, [url, keyBySrc]);
+
+ return <img key={key} 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..5d3ef630
--- /dev/null
+++ b/FrontEnd/src/components/Card.tsx
@@ -0,0 +1,39 @@
+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..3c83b0e9
--- /dev/null
+++ b/FrontEnd/src/components/Icon.css
@@ -0,0 +1,4 @@
+.cru-icon {
+ color: var(--cru-theme-color);
+ font-size: 1.4rem;
+}
diff --git a/FrontEnd/src/components/Icon.tsx b/FrontEnd/src/components/Icon.tsx
new file mode 100644
index 00000000..e5cf598e
--- /dev/null
+++ b/FrontEnd/src/components/Icon.tsx
@@ -0,0 +1,28 @@
+import { ComponentPropsWithoutRef } from "react";
+import classNames from "classnames";
+
+import { ThemeColor } from "./common";
+
+import "./Icon.css";
+
+interface IconButtonProps extends ComponentPropsWithoutRef<"i"> {
+ icon: string;
+ color?: ThemeColor;
+ size?: string | number;
+}
+
+export default function Icon(props: IconButtonProps) {
+ const { icon, color, size, style, className, ...otherProps } = props;
+
+ return (
+ <i
+ style={size != null ? { ...style, fontSize: size } : style}
+ className={classNames(
+ `cru-theme-${color ?? "primary"}`,
+ `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..03d2038f
--- /dev/null
+++ b/FrontEnd/src/components/ImageCropper.css
@@ -0,0 +1,34 @@
+.cru-image-cropper-container {
+ position: relative;
+ box-sizing: border-box;
+ display: flex;
+ user-select: none;
+}
+
+.cru-image-cropper-container img {
+ width: 100%;
+ height: 100%;
+}
+
+.cru-image-cropper-mask-container {
+ position: absolute;
+ left: 0;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ overflow: hidden;
+}
+
+.cru-image-cropper-mask {
+ position: absolute;
+ box-shadow: 0 0 0 10000px rgba(255, 255, 255, 0.8);
+ touch-action: none;
+}
+
+.cru-image-cropper-handler {
+ position: absolute;
+ border: black solid 2px;
+ border-radius: 50%;
+ background: white;
+ touch-action: none;
+} \ No newline at end of file
diff --git a/FrontEnd/src/components/ImageCropper.tsx b/FrontEnd/src/components/ImageCropper.tsx
new file mode 100644
index 00000000..4dfdd0cd
--- /dev/null
+++ b/FrontEnd/src/components/ImageCropper.tsx
@@ -0,0 +1,323 @@
+import { useState, useRef, PointerEvent } from "react";
+import classnames from "classnames";
+
+import { UiLogicError, geometry } from "./common";
+
+import BlobImage from "./BlobImage";
+
+import "./ImageCropper.css";
+
+const { Rect } = geometry;
+
+type Rect = geometry.Rect;
+type Movement = geometry.Movement;
+
+export function crop(
+ image: HTMLImageElement,
+ clip: Rect,
+ mimeType: string,
+): Promise<Blob> {
+ return new Promise((resolve, reject) => {
+ const canvas = document.createElement("canvas");
+ canvas.width = clip.width;
+ canvas.height = clip.height;
+ const context = canvas.getContext("2d");
+
+ if (context == null) throw new Error("Failed to create context.");
+
+ context.drawImage(
+ image,
+ clip.left,
+ clip.top,
+ clip.width,
+ clip.height,
+ 0,
+ 0,
+ clip.width,
+ clip.height,
+ );
+
+ canvas.toBlob((blob) => {
+ if (blob == null) {
+ reject(new Error("canvas.toBlob returns null"));
+ } else {
+ resolve(blob);
+ }
+ }, mimeType);
+ });
+}
+
+interface ImageInfo {
+ element: HTMLImageElement;
+ width: number;
+ height: number;
+ ratio: number;
+ landscape: boolean;
+ rect: Rect;
+}
+
+export interface CropConstraint {
+ ratio?: number;
+ // minClipWidth?: number;
+ // minClipHeight?: number;
+ // maxClipWidth?: number;
+ // maxClipHeight?: number;
+}
+
+function generateImageInfo(imageElement: HTMLImageElement): ImageInfo {
+ const { naturalWidth, naturalHeight } = imageElement;
+ const imageRatio = naturalHeight / naturalWidth;
+
+ return {
+ element: imageElement,
+ width: naturalWidth,
+ height: naturalHeight,
+ ratio: imageRatio,
+ landscape: imageRatio < 1,
+ rect: new Rect(0, 0, naturalWidth, naturalHeight),
+ };
+}
+
+interface ImageCropperProps {
+ clip: Rect;
+ image: Blob | string | null;
+ imageElementCallback: (element: HTMLImageElement | null) => void;
+ onImageLoad: () => void;
+ onMove: (movement: Movement, originalClip: Rect) => void;
+ onResize: (movement: Movement, originalClip: Rect) => void;
+ containerClassName?: string;
+}
+
+export function useImageCrop(
+ file: File | null,
+ options?: {
+ constraint?: CropConstraint;
+ },
+): {
+ clip: Rect;
+ setClip: (clip: Rect) => void;
+ canCrop: boolean;
+ crop: () => Promise<Blob>;
+ imageCropperProps: ImageCropperProps;
+} {
+ const targetRatio = options?.constraint?.ratio;
+
+ const [imageElement, setImageElement] = useState<HTMLImageElement | null>(
+ null,
+ );
+ const [imageInfo, setImageInfo] = useState<ImageInfo | null>(null);
+ const [clip, setClip] = useState<Rect>(Rect.empty);
+
+ if (imageElement == null && imageInfo != null) {
+ setImageInfo(null);
+ setClip(Rect.empty);
+ }
+
+ const canCrop = file != null && imageElement != null && imageInfo != null;
+
+ return {
+ clip,
+ setClip,
+ canCrop,
+ crop() {
+ if (!canCrop) throw new UiLogicError();
+ return crop(imageElement, clip, file.type);
+ },
+ imageCropperProps: {
+ clip,
+ image: file,
+ imageElementCallback: setImageElement,
+ onMove: (movement, originalClip) => {
+ if (imageInfo == null) return;
+ const newClip = geometry.adjustRectToContainer(
+ originalClip.copy().move(movement),
+ imageInfo.rect,
+ "move",
+ {
+ targetRatio,
+ },
+ );
+ setClip(newClip);
+ },
+ onResize: (movement, originalClip) => {
+ if (imageInfo == null) return;
+ const newClip = geometry.adjustRectToContainer(
+ originalClip.copy().expand(movement),
+ imageInfo.rect,
+ "resize",
+ { targetRatio, resizeNoFlip: true, ratioCorrectBasedOn: "width" },
+ );
+ setClip(newClip);
+ },
+ onImageLoad: () => {
+ if (imageElement == null) throw new UiLogicError();
+ const image = generateImageInfo(imageElement);
+ setImageInfo(image);
+ setClip(
+ geometry.adjustRectToContainer(Rect.max, image.rect, "both", {
+ targetRatio,
+ }),
+ );
+ },
+ },
+ };
+}
+
+interface PointerState {
+ x: number;
+ y: number;
+ pointerId: number;
+ originalClip: Rect;
+}
+
+const imageCropperHandlerSize = 15;
+
+export function ImageCropper(props: ImageCropperProps) {
+ function convertClipToElement(
+ clip: Rect,
+ imageElement: HTMLImageElement,
+ ): Rect {
+ const xRatio = imageElement.clientWidth / imageElement.naturalWidth;
+ const yRatio = imageElement.clientHeight / imageElement.naturalHeight;
+ return Rect.from({
+ left: xRatio * clip.left,
+ top: yRatio * clip.top,
+ width: xRatio * clip.width,
+ height: yRatio * clip.height,
+ });
+ }
+
+ function convertMovementFromElement(
+ move: Movement,
+ imageElement: HTMLImageElement,
+ ): Movement {
+ const xRatio = imageElement.naturalWidth / imageElement.clientWidth;
+ const yRatio = imageElement.naturalHeight / imageElement.clientHeight;
+ return {
+ x: xRatio * move.x,
+ y: yRatio * move.y,
+ };
+ }
+
+ const {
+ clip,
+ image,
+ imageElementCallback,
+ onImageLoad,
+ onMove,
+ onResize,
+ containerClassName,
+ } = props;
+
+ const pointerStateRef = useRef<PointerState | null>(null);
+ const [imageElement, setImageElement] = useState<HTMLImageElement | null>(
+ null,
+ );
+
+ const clipInElement: Rect =
+ imageElement != null
+ ? convertClipToElement(clip, imageElement)
+ : Rect.empty;
+
+ const actOnMovement = (
+ e: PointerEvent,
+ change: (movement: Movement, originalClip: Rect) => void,
+ ) => {
+ if (
+ imageElement == null ||
+ pointerStateRef.current == null ||
+ pointerStateRef.current.pointerId != e.pointerId
+ ) {
+ return;
+ }
+
+ const { x, y, originalClip } = pointerStateRef.current;
+
+ const movement = {
+ x: e.clientX - x,
+ y: e.clientY - y,
+ };
+
+ change(convertMovementFromElement(movement, imageElement), originalClip);
+ };
+
+ const onPointerDown = (e: PointerEvent) => {
+ if (imageElement == null || pointerStateRef.current != null) return;
+
+ e.currentTarget.setPointerCapture(e.pointerId);
+
+ pointerStateRef.current = {
+ x: e.clientX,
+ y: e.clientY,
+ pointerId: e.pointerId,
+ originalClip: clip,
+ };
+ };
+
+ const onPointerUp = (e: PointerEvent) => {
+ if (
+ pointerStateRef.current == null ||
+ pointerStateRef.current.pointerId != e.pointerId
+ ) {
+ return;
+ }
+
+ e.currentTarget.releasePointerCapture(e.pointerId);
+ pointerStateRef.current = null;
+ };
+
+ const onMaskPointerMove = (e: PointerEvent) => {
+ actOnMovement(e, onMove);
+ };
+
+ const onResizeHandlerPointerMove = (e: PointerEvent) => {
+ actOnMovement(e, onResize);
+ };
+
+ return (
+ <div
+ className={classnames("cru-image-cropper-container", containerClassName)}
+ >
+ <BlobImage
+ imgRef={(element) => {
+ setImageElement(element);
+ imageElementCallback(element);
+ }}
+ src={image}
+ onLoad={onImageLoad}
+ />
+ <div className="cru-image-cropper-mask-container">
+ <div
+ className="cru-image-cropper-mask"
+ style={
+ clipInElement == null
+ ? undefined
+ : {
+ left: clipInElement.left,
+ top: clipInElement.top,
+ width: clipInElement.width,
+ height: clipInElement.height,
+ }
+ }
+ onPointerMove={onMaskPointerMove}
+ onPointerDown={onPointerDown}
+ onPointerUp={onPointerUp}
+ />
+ </div>
+ <div
+ className="cru-image-cropper-handler"
+ style={{
+ left:
+ clipInElement.left + clipInElement.width - imageCropperHandlerSize,
+ top:
+ clipInElement.top + clipInElement.height - imageCropperHandlerSize,
+ width: imageCropperHandlerSize * 2,
+ height: imageCropperHandlerSize * 2,
+ }}
+ onPointerMove={onResizeHandlerPointerMove}
+ onPointerDown={onPointerDown}
+ onPointerUp={onPointerUp}
+ />
+ </div>
+ );
+}
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/Page.css b/FrontEnd/src/components/Page.css
new file mode 100644
index 00000000..b22d83af
--- /dev/null
+++ b/FrontEnd/src/components/Page.css
@@ -0,0 +1,8 @@
+.cru-page {
+ padding: var(--cru-page-padding);
+}
+
+.cru-page-no-top-padding {
+ padding-top: 0;
+}
+
diff --git a/FrontEnd/src/components/Page.tsx b/FrontEnd/src/components/Page.tsx
new file mode 100644
index 00000000..8c9febcc
--- /dev/null
+++ b/FrontEnd/src/components/Page.tsx
@@ -0,0 +1,17 @@
+import { ComponentPropsWithoutRef, Ref } from "react";
+import classNames from "classnames";
+
+import "./Page.css";
+
+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..818b2917
--- /dev/null
+++ b/FrontEnd/src/components/SearchInput.css
@@ -0,0 +1,8 @@
+.cru-search-input {
+ display: flex;
+ gap: 1em;
+}
+
+.cru-search-input-input {
+ width: 100%;
+} \ No newline at end of file
diff --git a/FrontEnd/src/components/SearchInput.tsx b/FrontEnd/src/components/SearchInput.tsx
new file mode 100644
index 00000000..b1de6227
--- /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";
+
+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="search"
+ 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..0f78d3b5
--- /dev/null
+++ b/FrontEnd/src/components/Skeleton.css
@@ -0,0 +1,20 @@
+.cru-skeleton {
+ padding: 0 1em;
+}
+
+.cru-skeleton-line {
+ height: 1em;
+ background-color: hsl(0 0% 90%);
+ margin: 0.7em 0;
+ border-radius: 0.2em;
+}
+
+@media (prefers-color-scheme: dark) {
+ .cru-skeleton-line {
+ background-color: hsl(0 0% 20%);
+ }
+}
+
+.cru-skeleton-line:last-child {
+ width: 50%;
+}
diff --git a/FrontEnd/src/components/Skeleton.tsx b/FrontEnd/src/components/Skeleton.tsx
new file mode 100644
index 00000000..03f80df5
--- /dev/null
+++ b/FrontEnd/src/components/Skeleton.tsx
@@ -0,0 +1,22 @@
+import { ComponentPropsWithoutRef } from "react";
+import classNames from "classnames";
+
+import { range } from "~src/utilities";
+
+import "./Skeleton.css";
+
+interface SkeletonProps extends ComponentPropsWithoutRef<"div"> {
+ lineNumber?: number;
+}
+
+export default function Skeleton(props: SkeletonProps) {
+ const { lineNumber, className, ...otherProps } = props;
+
+ return (
+ <div className={classNames(className, "cru-skeleton")} {...otherProps}>
+ {range(lineNumber ?? 3).map((i) => (
+ <div key={i} className="cru-skeleton-line" />
+ ))}
+ </div>
+ );
+}
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..50ccf0b2
--- /dev/null
+++ b/FrontEnd/src/components/Spinner.tsx
@@ -0,0 +1,46 @@
+import { CSSProperties, ComponentPropsWithoutRef } from "react";
+import classNames from "classnames";
+
+import "./Spinner.css";
+
+const sizeMap: Record<string, string> = {
+ sm: "18px",
+ md: "30px",
+ lg: "42px",
+};
+
+function calculateSize(size: SpinnerProps["size"]) {
+ if (size == null) {
+ return "1em";
+ }
+ if (typeof size === "number") {
+ return size;
+ }
+ if (size in sizeMap) {
+ return sizeMap[size];
+ }
+ return size;
+}
+
+export interface SpinnerProps extends ComponentPropsWithoutRef<"span"> {
+ size?: number | string;
+ className?: string;
+ style?: CSSProperties;
+}
+
+export default function Spinner(props: SpinnerProps) {
+ const { size, className, style, ...otherProps } = props;
+ const calculatedSize = calculateSize(size);
+
+ return (
+ <span
+ className={classNames("cru-spinner", className)}
+ style={{
+ width: calculatedSize,
+ height: calculatedSize,
+ ...style,
+ }}
+ {...otherProps}
+ />
+ );
+}
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..59f8f27c
--- /dev/null
+++ b/FrontEnd/src/components/alert/AlertHost.tsx
@@ -0,0 +1,82 @@
+import { useEffect, useState } from "react";
+import classNames from "classnames";
+
+import { ThemeColor, useC, Text } from "../common";
+import IconButton from "../button/IconButton";
+
+import { alertService, AlertInfoWithId } from "./AlertService";
+
+import "./alert.css";
+
+interface AutoCloseAlertProps {
+ color: ThemeColor;
+ message: Text;
+ onDismiss?: () => void;
+ onIn?: () => void;
+ onOut?: () => void;
+}
+
+function Alert({
+ color,
+ message,
+ onDismiss,
+ onIn,
+ onOut,
+}: AutoCloseAlertProps) {
+ const c = useC();
+
+ return (
+ <div
+ className={classNames("cru-alert", `cru-theme-${color}`)}
+ onPointerEnter={onIn}
+ onPointerLeave={onOut}
+ >
+ <div className="cru-alert-message">{c(message)}</div>
+ <IconButton
+ icon="x"
+ color="danger"
+ className="cru-alert-close-button"
+ onClick={onDismiss}
+ />
+ </div>
+ );
+}
+
+export default function AlertHost() {
+ const [alerts, setAlerts] = useState<AlertInfoWithId[]>([]);
+
+ useEffect(() => {
+ const listener = (alerts: AlertInfoWithId[]) => {
+ setAlerts(alerts);
+ };
+
+ alertService.registerListener(listener);
+
+ return () => {
+ alertService.unregisterListener(listener);
+ };
+ }, []);
+
+ return (
+ <div className="alert-container">
+ {alerts.map((alert) => {
+ return (
+ <Alert
+ key={alert.id}
+ message={alert.message}
+ color={alert.color ?? "primary"}
+ onIn={() => {
+ alertService.clearDismissTimer(alert.id);
+ }}
+ onOut={() => {
+ alertService.resetDismissTimer(alert.id);
+ }}
+ onDismiss={() => {
+ alertService.dismiss(alert.id);
+ }}
+ />
+ );
+ })}
+ </div>
+ );
+}
diff --git a/FrontEnd/src/components/alert/AlertService.ts b/FrontEnd/src/components/alert/AlertService.ts
new file mode 100644
index 00000000..b9cda752
--- /dev/null
+++ b/FrontEnd/src/components/alert/AlertService.ts
@@ -0,0 +1,114 @@
+import { ThemeColor, Text } from "../common";
+
+const defaultDismissTime = 5000;
+
+export interface AlertInfo {
+ color?: ThemeColor;
+ message: Text;
+ dismissTime?: number | "never";
+}
+
+export interface AlertInfoWithId extends AlertInfo {
+ id: number;
+}
+
+interface AlertServiceAlert extends AlertInfoWithId {
+ timerId: number | null;
+}
+
+export type AlertsListener = (alerts: AlertInfoWithId[]) => void;
+
+export class AlertService {
+ private listeners: AlertsListener[] = [];
+ private alerts: AlertServiceAlert[] = [];
+ private currentId = 1;
+
+ getAlert(alertId?: number | null | undefined): AlertServiceAlert | null {
+ for (const alert of this.alerts) {
+ if (alert.id === alertId) return alert;
+ }
+ return null;
+ }
+
+ registerListener(listener: AlertsListener): void {
+ this.listeners.push(listener);
+ listener(this.alerts);
+ }
+
+ unregisterListener(listener: AlertsListener): void {
+ this.listeners = this.listeners.filter((l) => l !== listener);
+ }
+
+ notify() {
+ for (const listener of this.listeners) {
+ listener(this.alerts);
+ }
+ }
+
+ push(alert: AlertInfo): void {
+ const newAlert: AlertServiceAlert = {
+ ...alert,
+ id: this.currentId++,
+ timerId: null,
+ };
+
+ this.alerts = [...this.alerts, newAlert];
+ this._resetDismissTimer(newAlert);
+
+ this.notify();
+ }
+
+ private _dismiss(alert: AlertServiceAlert) {
+ if (alert.timerId != null) {
+ window.clearTimeout(alert.timerId);
+ }
+ this.alerts = this.alerts.filter((a) => a !== alert);
+ this.notify();
+ }
+
+ dismiss(alertId?: number | null | undefined) {
+ const alert = this.getAlert(alertId);
+ if (alert != null) {
+ this._dismiss(alert);
+ }
+ }
+
+ private _clearDismissTimer(alert: AlertServiceAlert) {
+ if (alert.timerId != null) {
+ window.clearTimeout(alert.timerId);
+ alert.timerId = null;
+ }
+ }
+
+ clearDismissTimer(alertId?: number | null | undefined) {
+ const alert = this.getAlert(alertId);
+ if (alert != null) {
+ this._clearDismissTimer(alert);
+ }
+ }
+
+ private _resetDismissTimer(
+ alert: AlertServiceAlert,
+ dismissTime?: number | null | undefined,
+ ) {
+ this._clearDismissTimer(alert);
+
+ const realDismissTime =
+ dismissTime ?? alert.dismissTime ?? defaultDismissTime;
+
+ if (typeof realDismissTime === "number") {
+ alert.timerId = window.setTimeout(() => {
+ this._dismiss(alert);
+ }, realDismissTime);
+ }
+ }
+
+ resetDismissTimer(alertId?: number | null | undefined) {
+ const alert = this.getAlert(alertId);
+ if (alert != null) {
+ this._resetDismissTimer(alert);
+ }
+ }
+}
+
+export const alertService = new AlertService();
diff --git a/FrontEnd/src/components/alert/alert.css b/FrontEnd/src/components/alert/alert.css
new file mode 100644
index 00000000..948256de
--- /dev/null
+++ b/FrontEnd/src/components/alert/alert.css
@@ -0,0 +1,21 @@
+.alert-container {
+ position: fixed;
+ z-index: 1040;
+}
+
+.cru-alert {
+ border-radius: 5px;
+ border: var(--cru-theme-color) 2px solid;
+ color: var(--cru-text-primary-color);
+ background-color: var(--cru-container-background-color);
+
+ margin: 1em;
+ padding: 0.5em 1em;
+
+ display: flex;
+ align-items: center;
+}
+
+.cru-alert-close-button {
+ margin-left: auto;
+} \ No newline at end of file
diff --git a/FrontEnd/src/components/alert/index.ts b/FrontEnd/src/components/alert/index.ts
new file mode 100644
index 00000000..1be0c2ec
--- /dev/null
+++ b/FrontEnd/src/components/alert/index.ts
@@ -0,0 +1,8 @@
+import { alertService, AlertInfo } from "./AlertService";
+import { default as AlertHost } from "./AlertHost";
+
+export { alertService, AlertHost };
+
+export function pushAlert(alert: AlertInfo): void {
+ alertService.push(alert);
+}
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..30ea8c11
--- /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, ClickableColor } from "../common";
+
+import "./Button.css";
+
+interface ButtonProps extends ComponentPropsWithoutRef<"button"> {
+ color?: ClickableColor;
+ 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..a54425cc
--- /dev/null
+++ b/FrontEnd/src/components/button/ButtonRowV2.tsx
@@ -0,0 +1,146 @@
+import { ComponentPropsWithoutRef, Ref } from "react";
+import classNames from "classnames";
+
+import { Text, ClickableColor } from "../common";
+
+import Button from "./Button";
+import FlatButton from "./FlatButton";
+import IconButton from "./IconButton";
+import LoadingButton from "./LoadingButton";
+
+import "./ButtonRow.css";
+
+type ButtonAction = "major" | "minor";
+
+interface ButtonRowV2ButtonBase {
+ key: string | number;
+ action?: ButtonAction;
+ color?: ClickableColor;
+ 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: ButtonAction = action ?? "minor";
+ const realColor =
+ color ?? (realAction === "major" ? "primary" : "minor");
+
+ 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 !== "major"}
+ {...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..aad02e76
--- /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, ClickableColor } from "../common";
+
+import "./FlatButton.css";
+
+interface FlatButtonProps extends ComponentPropsWithoutRef<"button"> {
+ color?: ClickableColor;
+ 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..e0862167
--- /dev/null
+++ b/FrontEnd/src/components/button/IconButton.tsx
@@ -0,0 +1,30 @@
+import { ComponentPropsWithoutRef } from "react";
+import classNames from "classnames";
+
+import { ClickableColor } from "../common";
+
+import "./IconButton.css";
+
+interface IconButtonProps extends ComponentPropsWithoutRef<"i"> {
+ icon: string;
+ color?: ClickableColor;
+ 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..9d65a2b3
--- /dev/null
+++ b/FrontEnd/src/components/button/LoadingButton.tsx
@@ -0,0 +1,39 @@
+import classNames from "classnames";
+
+import { I18nText, ClickableColor, useC } from "../common";
+import Spinner from "../Spinner";
+
+import "./LoadingButton.css";
+
+interface LoadingButtonProps extends React.ComponentPropsWithoutRef<"button"> {
+ color?: ClickableColor;
+ 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..a6c3e705
--- /dev/null
+++ b/FrontEnd/src/components/common.ts
@@ -0,0 +1,22 @@
+import "./index.css";
+
+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 type ClickableColor = ThemeColor | "grayscale" | "light" | "minor";
+
+export { breakpoints } from "./breakpoints";
+
+export * as geometry from "~src/utilities/geometry";
+
+export * as array from "~src/utilities/array"
+
diff --git a/FrontEnd/src/components/dialog/ConfirmDialog.tsx b/FrontEnd/src/components/dialog/ConfirmDialog.tsx
new file mode 100644
index 00000000..8b0a4219
--- /dev/null
+++ b/FrontEnd/src/components/dialog/ConfirmDialog.tsx
@@ -0,0 +1,54 @@
+import { useC, Text, ThemeColor } from "../common";
+
+import Dialog from "./Dialog";
+import DialogContainer from "./DialogContainer";
+import { useCloseDialog } from "./DialogProvider";
+
+export default function ConfirmDialog({
+ onConfirm,
+ title,
+ body,
+ color,
+}: {
+ onConfirm: () => void;
+ title: Text;
+ body: Text;
+ color?: ThemeColor;
+ bodyColor?: ThemeColor;
+}) {
+ const c = useC();
+
+ const closeDialog = useCloseDialog();
+
+ return (
+ <Dialog color={color ?? "danger"}>
+ <DialogContainer
+ title={title}
+ titleColor={color ?? "danger"}
+ buttonsV2={[
+ {
+ key: "cancel",
+ type: "normal",
+ action: "minor",
+
+ text: "operationDialog.cancel",
+ onClick: closeDialog,
+ },
+ {
+ key: "confirm",
+ type: "normal",
+ action: "major",
+ text: "operationDialog.confirm",
+ color: "danger",
+ onClick: () => {
+ onConfirm();
+ closeDialog();
+ },
+ },
+ ]}
+ >
+ <div>{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..23b663db
--- /dev/null
+++ b/FrontEnd/src/components/dialog/Dialog.css
@@ -0,0 +1,39 @@
+.cru-dialog-overlay {
+ position: fixed;
+ z-index: 1040;
+ left: 0;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ overflow: auto;
+ padding: 20vh 1em;
+}
+
+.cru-dialog-background {
+ position: absolute;
+ z-index: -1;
+ left: 0;
+ right: 0;
+ top: 0;
+ bottom: 0;
+ background-color: var(--cru-dialog-overlay-color);
+ opacity: 0.8;
+}
+
+.cru-dialog-container {
+ max-width: 100%;
+ min-width: 30vw;
+
+ margin: 2em auto;
+
+ border: var(--cru-theme-color) 2px solid;
+ border-radius: 5px;
+ padding: 1.5em;
+ background-color: var(--cru-dialog-container-background-color);
+}
+
+@media (min-width: 576px) {
+ .cru-dialog-container {
+ max-width: 800px;
+ }
+}
diff --git a/FrontEnd/src/components/dialog/Dialog.tsx b/FrontEnd/src/components/dialog/Dialog.tsx
new file mode 100644
index 00000000..043a8eec
--- /dev/null
+++ b/FrontEnd/src/components/dialog/Dialog.tsx
@@ -0,0 +1,55 @@
+import { ReactNode, useRef } from "react";
+import ReactDOM from "react-dom";
+import classNames from "classnames";
+
+import { ThemeColor, UiLogicError } from "../common";
+
+import { useCloseDialog } from "./DialogProvider";
+
+import "./Dialog.css";
+
+const optionalPortalElement = document.getElementById("portal");
+if (optionalPortalElement == null) {
+ throw new UiLogicError();
+}
+const portalElement = optionalPortalElement;
+
+interface DialogProps {
+ color?: ThemeColor;
+ children?: ReactNode;
+ disableCloseOnClickOnOverlay?: boolean;
+}
+
+export default function Dialog({
+ color,
+ children,
+ disableCloseOnClickOnOverlay,
+}: DialogProps) {
+ const closeDialog = useCloseDialog();
+
+ const lastPointerDownIdRef = useRef<number | null>(null);
+
+ return ReactDOM.createPortal(
+ <div
+ className={classNames(
+ `cru-theme-${color ?? "primary"}`,
+ "cru-dialog-overlay",
+ )}
+ >
+ <div
+ className="cru-dialog-background"
+ onPointerDown={(e) => {
+ lastPointerDownIdRef.current = e.pointerId;
+ }}
+ onPointerUp={(e) => {
+ if (lastPointerDownIdRef.current === e.pointerId) {
+ if (!disableCloseOnClickOnOverlay) closeDialog();
+ }
+ lastPointerDownIdRef.current = null;
+ }}
+ />
+ <div className="cru-dialog-container">{children}</div>
+ </div>,
+ portalElement,
+ );
+}
diff --git a/FrontEnd/src/components/dialog/DialogContainer.css b/FrontEnd/src/components/dialog/DialogContainer.css
new file mode 100644
index 00000000..f0d27a66
--- /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-theme-color);
+ margin-bottom: 0.5em;
+}
+
+.cru-dialog-container-hr {
+ margin: 1em 0;
+ border-color: var(--cru-text-minor-color);
+}
+
+.cru-dialog-container-button-row {
+ display: flex;
+ justify-content: flex-end;
+}
+
+.cru-dialog-container-button {
+ margin-left: 1em;
+}
diff --git a/FrontEnd/src/components/dialog/DialogContainer.tsx b/FrontEnd/src/components/dialog/DialogContainer.tsx
new file mode 100644
index 00000000..6ee4e134
--- /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-theme-${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/DialogProvider.tsx b/FrontEnd/src/components/dialog/DialogProvider.tsx
new file mode 100644
index 00000000..bb85e4cf
--- /dev/null
+++ b/FrontEnd/src/components/dialog/DialogProvider.tsx
@@ -0,0 +1,95 @@
+import { useState, useContext, createContext, ReactNode } from "react";
+
+import { UiLogicError } from "../common";
+
+type DialogMap<D extends string> = {
+ [K in D]: ReactNode;
+};
+
+interface DialogController<D extends string> {
+ currentDialog: D | null;
+ currentDialogReactNode: ReactNode;
+ canSwitchDialog: boolean;
+ switchDialog: (newDialog: D | null) => void;
+ setCanSwitchDialog: (enable: boolean) => void;
+ closeDialog: () => void;
+ forceSwitchDialog: (newDialog: D | null) => void;
+ forceCloseDialog: () => void;
+}
+
+export function useDialog<D extends string>(
+ dialogs: DialogMap<D>,
+ options?: {
+ initDialog?: D | null;
+ onClose?: {
+ [K in D]?: () => void;
+ };
+ },
+): {
+ controller: DialogController<D>;
+ switchDialog: (newDialog: D | null) => void;
+ forceSwitchDialog: (newDialog: D | null) => void;
+ createDialogSwitch: (newDialog: D | null) => () => void;
+} {
+ const [canSwitchDialog, setCanSwitchDialog] = useState<boolean>(true);
+ const [dialog, setDialog] = useState<D | null>(options?.initDialog ?? null);
+
+ const forceSwitchDialog = (newDialog: D | null) => {
+ if (dialog != null) {
+ options?.onClose?.[dialog]?.();
+ }
+ setDialog(newDialog);
+ setCanSwitchDialog(true);
+ };
+
+ const switchDialog = (newDialog: D | null) => {
+ if (canSwitchDialog) {
+ forceSwitchDialog(newDialog);
+ }
+ };
+
+ const controller: DialogController<D> = {
+ currentDialog: dialog,
+ currentDialogReactNode: dialog == null ? null : dialogs[dialog],
+ canSwitchDialog,
+ switchDialog,
+ setCanSwitchDialog,
+ closeDialog: () => switchDialog(null),
+ forceSwitchDialog,
+ forceCloseDialog: () => forceSwitchDialog(null),
+ };
+
+ return {
+ controller,
+ switchDialog,
+ forceSwitchDialog,
+ createDialogSwitch: (newDialog: D | null) => () => switchDialog(newDialog),
+ };
+}
+
+const DialogControllerContext = createContext<DialogController<string> | null>(
+ null,
+);
+
+export function useDialogController(): DialogController<string> {
+ const controller = useContext(DialogControllerContext);
+ if (controller == null) throw new UiLogicError("not in dialog provider");
+ return controller;
+}
+
+export function useCloseDialog(): () => void {
+ const controller = useDialogController();
+ return controller.closeDialog;
+}
+
+export function DialogProvider<D extends string>({
+ controller,
+}: {
+ controller: DialogController<D>;
+}) {
+ return (
+ <DialogControllerContext.Provider value={controller as never}>
+ {controller.currentDialogReactNode}
+ </DialogControllerContext.Provider>
+ );
+}
diff --git a/FrontEnd/src/components/dialog/FullPageDialog.css b/FrontEnd/src/components/dialog/FullPageDialog.css
new file mode 100644
index 00000000..ce07c6ac
--- /dev/null
+++ b/FrontEnd/src/components/dialog/FullPageDialog.css
@@ -0,0 +1,30 @@
+.cru-dialog-full-page {
+ position: fixed;
+ z-index: 1030;
+ left: 0;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ background-color: var(--cru-background-color);
+ padding-top: 56px;
+}
+
+.cru-dialog-full-page-top-bar {
+ height: 56px;
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ z-index: 1;
+ background-color: var(--cru-theme-color);
+ display: flex;
+ align-items: center;
+}
+
+.cru-dialog-full-page-content-container {
+ overflow: scroll;
+}
+
+.cru-dialog-full-page-back-button {
+ margin-left: 0.5em;
+}
diff --git a/FrontEnd/src/components/dialog/FullPageDialog.tsx b/FrontEnd/src/components/dialog/FullPageDialog.tsx
new file mode 100644
index 00000000..575abf7f
--- /dev/null
+++ b/FrontEnd/src/components/dialog/FullPageDialog.tsx
@@ -0,0 +1,52 @@
+import { ReactNode } from "react";
+import { createPortal } from "react-dom";
+import classNames from "classnames";
+
+import { ThemeColor, UiLogicError } from "../common";
+import { IconButton } from "../button";
+
+import { useCloseDialog } from "./DialogProvider";
+
+import "./FullPageDialog.css";
+
+const optionalPortalElement = document.getElementById("portal");
+if (optionalPortalElement == null) {
+ throw new UiLogicError();
+}
+const portalElement = optionalPortalElement;
+
+interface FullPageDialogProps {
+ color?: ThemeColor;
+ contentContainerClassName?: string;
+ children: ReactNode;
+}
+
+export default function FullPageDialog({
+ color,
+ children,
+ contentContainerClassName,
+}: FullPageDialogProps) {
+ const closeDialog = useCloseDialog();
+
+ return createPortal(
+ <div className={`cru-dialog-full-page cru-theme-${color ?? "primary"}`}>
+ <div className="cru-dialog-full-page-top-bar">
+ <IconButton
+ icon="arrow-left"
+ color="light"
+ className="cru-dialog-full-page-back-button"
+ onClick={closeDialog}
+ />
+ </div>
+ <div
+ className={classNames(
+ "cru-dialog-full-page-content-container",
+ contentContainerClassName,
+ )}
+ >
+ {children}
+ </div>
+ </div>,
+ portalElement,
+ );
+}
diff --git a/FrontEnd/src/components/dialog/OperationDialog.css b/FrontEnd/src/components/dialog/OperationDialog.css
new file mode 100644
index 00000000..28f73c9d
--- /dev/null
+++ b/FrontEnd/src/components/dialog/OperationDialog.css
@@ -0,0 +1,4 @@
+.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..6ca4d0a0
--- /dev/null
+++ b/FrontEnd/src/components/dialog/OperationDialog.tsx
@@ -0,0 +1,221 @@
+import { useState, ReactNode, ComponentProps } from "react";
+import classNames from "classnames";
+
+import { useC, Text, ThemeColor } from "../common";
+import {
+ useInputs,
+ InputGroup,
+ Initializer as InputInitializer,
+ InputConfirmValueDict,
+} from "../input";
+import { ButtonRowV2 } from "../button";
+import Dialog from "./Dialog";
+import DialogContainer from "./DialogContainer";
+import { useDialogController } from "./DialogProvider";
+
+import "./OperationDialog.css";
+
+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> {
+ 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 {
+ 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 dialogController = useDialogController();
+
+ const [step, setStep] = useState<Step>({ type: "input" });
+
+ const { inputGroupProps, hasErrorAndDirty, setAllDisabled, confirm } =
+ useInputs({
+ init: inputs,
+ });
+
+ function close() {
+ if (step.type !== "process") {
+ dialogController.closeDialog();
+ 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" });
+ dialogController.setCanSwitchDialog(false);
+ setAllDisabled(true);
+ onProcess(result.values)
+ .then(
+ (d) => {
+ setStep({
+ type: "success",
+ data: d,
+ });
+ },
+ (e: unknown) => {
+ setStep({
+ type: "failure",
+ data: e,
+ });
+ },
+ )
+ .finally(() => {
+ dialogController.setCanSwitchDialog(true);
+ });
+ }
+ }
+
+ let body: ReactNode;
+ let buttons: ComponentProps<typeof ButtonRowV2>["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",
+ text: "operationDialog.cancel",
+ onClick: close,
+ disabled: isProcessing,
+ },
+ {
+ key: "confirm",
+ type: "loading",
+ action: "major",
+ 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",
+ action: "major",
+ color: "create",
+ text: "operationDialog.ok",
+ onClick: close,
+ },
+ ];
+ }
+
+ return (
+ <Dialog color={color}>
+ <DialogContainer title={title} titleColor={color} buttonsV2={buttons}>
+ {body}
+ </DialogContainer>
+ </Dialog>
+ );
+}
+
+export default OperationDialog;
diff --git a/FrontEnd/src/components/dialog/index.tsx b/FrontEnd/src/components/dialog/index.tsx
new file mode 100644
index 00000000..9ca06de2
--- /dev/null
+++ b/FrontEnd/src/components/dialog/index.tsx
@@ -0,0 +1,12 @@
+export { default as Dialog } from "./Dialog";
+export { default as FullPageDialog } from "./FullPageDialog";
+export { default as OperationDialog } from "./OperationDialog";
+export { default as ConfirmDialog } from "./ConfirmDialog";
+export { default as DialogContainer } from "./DialogContainer";
+
+export {
+ useDialog,
+ useDialogController,
+ useCloseDialog,
+ DialogProvider,
+} from "./DialogProvider";
diff --git a/FrontEnd/src/components/hooks/index.ts b/FrontEnd/src/components/hooks/index.ts
new file mode 100644
index 00000000..98ce729e
--- /dev/null
+++ b/FrontEnd/src/components/hooks/index.ts
@@ -0,0 +1,5 @@
+export { useMobile } from "./responsive";
+export { default as useClickOutside } from "./useClickOutside";
+export { default as useScrollToBottom } from "./useScrollToBottom";
+export { default as useWindowLeave } from "./useWindowLeave";
+export { default as useAutoUnsubscribePromise } from "./useAutoUnsubscribePromise";
diff --git a/FrontEnd/src/components/hooks/responsive.ts b/FrontEnd/src/components/hooks/responsive.ts
new file mode 100644
index 00000000..42c134ef
--- /dev/null
+++ b/FrontEnd/src/components/hooks/responsive.ts
@@ -0,0 +1,7 @@
+import { useMediaQuery } from "react-responsive";
+
+import { breakpoints } from "../breakpoints";
+
+export function useMobile(onChange?: (mobile: boolean) => void): boolean {
+ return useMediaQuery({ maxWidth: breakpoints.sm }, undefined, onChange);
+}
diff --git a/FrontEnd/src/components/hooks/useAutoUnsubscribePromise.ts b/FrontEnd/src/components/hooks/useAutoUnsubscribePromise.ts
new file mode 100644
index 00000000..01c5a1db
--- /dev/null
+++ b/FrontEnd/src/components/hooks/useAutoUnsubscribePromise.ts
@@ -0,0 +1,24 @@
+import { useEffect, DependencyList } from "react";
+
+export default function useAutoUnsubscribePromise<T>(
+ promiseGenerator: () => Promise<T> | null | undefined,
+ resultHandler: (data: T) => void,
+ dependencies?: DependencyList | undefined,
+) {
+ useEffect(() => {
+ let subscribe = true;
+ const promise = promiseGenerator();
+ if (promise) {
+ void promise.then((data) => {
+ if (subscribe) {
+ resultHandler(data);
+ }
+ });
+
+ return () => {
+ subscribe = false;
+ };
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [promiseGenerator, resultHandler, ...(dependencies ?? [])]);
+}
diff --git a/FrontEnd/src/components/hooks/useClickOutside.ts b/FrontEnd/src/components/hooks/useClickOutside.ts
new file mode 100644
index 00000000..828ce7e3
--- /dev/null
+++ b/FrontEnd/src/components/hooks/useClickOutside.ts
@@ -0,0 +1,38 @@
+import { useRef, useEffect } from "react";
+
+export default function useClickOutside(
+ element: HTMLElement | null | undefined,
+ onClickOutside: () => void,
+ nextTick?: boolean,
+): void {
+ const onClickOutsideRef = useRef<() => void>(onClickOutside);
+
+ useEffect(() => {
+ onClickOutsideRef.current = onClickOutside;
+ }, [onClickOutside]);
+
+ useEffect(() => {
+ if (element != null) {
+ const handler = (event: MouseEvent): void => {
+ let e: HTMLElement | null = event.target as HTMLElement;
+ while (e) {
+ if (e == element) {
+ return;
+ }
+ e = e.parentElement;
+ }
+ onClickOutsideRef.current();
+ };
+ if (nextTick) {
+ setTimeout(() => {
+ document.addEventListener("click", handler);
+ });
+ } else {
+ document.addEventListener("click", handler);
+ }
+ return () => {
+ document.removeEventListener("click", handler);
+ };
+ }
+ }, [element, nextTick]);
+}
diff --git a/FrontEnd/src/components/hooks/useScrollToBottom.ts b/FrontEnd/src/components/hooks/useScrollToBottom.ts
new file mode 100644
index 00000000..79fcda16
--- /dev/null
+++ b/FrontEnd/src/components/hooks/useScrollToBottom.ts
@@ -0,0 +1,44 @@
+import { useRef, useEffect } from "react";
+import { fromEvent, filter, throttleTime } from "rxjs";
+
+function useScrollToBottom(
+ handler: () => void,
+ enable = true,
+ option = {
+ maxOffset: 5,
+ throttle: 1000,
+ },
+): void {
+ const handlerRef = useRef<(() => void) | null>(null);
+
+ useEffect(() => {
+ handlerRef.current = handler;
+
+ return () => {
+ handlerRef.current = null;
+ };
+ }, [handler]);
+
+ useEffect(() => {
+ const subscription = fromEvent(window, "scroll")
+ .pipe(
+ filter(
+ () =>
+ window.scrollY >=
+ document.body.scrollHeight - window.innerHeight - option.maxOffset,
+ ),
+ throttleTime(option.throttle),
+ )
+ .subscribe(() => {
+ if (enable) {
+ handlerRef.current?.();
+ }
+ });
+
+ return () => {
+ subscription.unsubscribe();
+ };
+ }, [enable, option.maxOffset, option.throttle]);
+}
+
+export default useScrollToBottom;
diff --git a/FrontEnd/src/components/hooks/useWindowLeave.ts b/FrontEnd/src/components/hooks/useWindowLeave.ts
new file mode 100644
index 00000000..ecd999d4
--- /dev/null
+++ b/FrontEnd/src/components/hooks/useWindowLeave.ts
@@ -0,0 +1,22 @@
+import { useEffect } from "react";
+
+import { useC, Text } from "../common";
+
+export default function useWindowLeave(
+ allow: boolean,
+ message: Text = "timeline.confirmLeave",
+) {
+ const c = useC();
+
+ useEffect(() => {
+ if (!allow) {
+ window.onbeforeunload = () => {
+ return c(message);
+ };
+
+ return () => {
+ window.onbeforeunload = null;
+ };
+ }
+ }, [c, allow, message]);
+}
diff --git a/FrontEnd/src/components/index.css b/FrontEnd/src/components/index.css
new file mode 100644
index 00000000..83b48318
--- /dev/null
+++ b/FrontEnd/src/components/index.css
@@ -0,0 +1,49 @@
+@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-major-color);
+ line-height: 1.2;
+}
+
+textarea {
+ transition: border-color 0.3s;
+ border-color: var(--cru-text-minor-color);
+ background: var(--cru-background-color);
+}
+
+textarea:hover {
+ border-color: var(--cru-clickable-primary-hover-color);
+}
+
+textarea:focus {
+ border-color: var(--cru-clickable-primary-normal-color);
+}
+
+.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;
+ }
+}
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..47a43b38
--- /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;
+};
+
+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,
+ errors: GeneralInputErrorDict,
+ inputs: InputInfo[],
+) => void;
+
+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 {
+ const errors: GeneralInputErrorDict = {};
+ validator?.(values, errors, inputs);
+ return cleanObject(errors);
+}
+
+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..c27e67d4
--- /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..49468bc2
--- /dev/null
+++ b/FrontEnd/src/components/list/ListItemContainer.css
@@ -0,0 +1,7 @@
+.cru-list-item-container {
+ border-bottom: 1px solid var(--cru-clickable-primary-normal-color);
+}
+
+.cru-list-item-container:last-child {
+ border-bottom: none;
+}
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..1a196a69
--- /dev/null
+++ b/FrontEnd/src/components/menu/Menu.tsx
@@ -0,0 +1,62 @@
+import { MouseEvent, CSSProperties } from "react";
+import classNames from "classnames";
+
+import { useC, Text, ThemeColor } from "../common";
+import Icon from "../Icon";
+
+import "./Menu.css";
+
+export type MenuItem =
+ | {
+ type: "divider";
+ }
+ | {
+ type: "button";
+ text: Text;
+ icon?: string;
+ color?: ThemeColor;
+ onClick?: (e: MouseEvent<HTMLButtonElement>) => void;
+ };
+
+export type MenuItems = MenuItem[];
+
+export type MenuProps = {
+ items: MenuItems;
+ onItemClick?: (e: MouseEvent<HTMLButtonElement>) => void;
+ className?: string;
+ style?: CSSProperties;
+};
+
+export default function Menu({
+ items,
+ onItemClick,
+ 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={(e) => {
+ onClick?.(e);
+ onItemClick?.(e);
+ }}
+ >
+ {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..7ac2abfe
--- /dev/null
+++ b/FrontEnd/src/components/menu/PopupMenu.tsx
@@ -0,0 +1,72 @@
+import { useState, CSSProperties, ReactNode } from "react";
+import classNames from "classnames";
+import { createPortal } from "react-dom";
+import { usePopper } from "react-popper";
+
+import { ThemeColor } from "../common";
+import { useClickOutside } from "../hooks";
+import Menu, { MenuItems } from "./Menu";
+
+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}
+ onItemClick={(e) => {
+ setShow(false);
+ e.stopPropagation();
+ }}
+ />
+ </div>,
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ document.getElementById("portal")!,
+ )}
+ </div>
+ );
+}
diff --git a/FrontEnd/src/components/tab/TabBar.css b/FrontEnd/src/components/tab/TabBar.css
new file mode 100644
index 00000000..dc6970c7
--- /dev/null
+++ b/FrontEnd/src/components/tab/TabBar.css
@@ -0,0 +1,32 @@
+.cru-tab-bar {
+ display: flex;
+}
+
+.cru-tab-bar-tab-area {
+ display: flex;
+ align-items: center;
+ border: var(--cru-clickable-normal-color) 1.6px solid;
+ border-radius: 3px;
+ overflow: hidden;
+}
+
+.cru-tab-bar-item {
+ color: var(--cru-text-minor-color);
+ transition: all 0.2s;
+ cursor: pointer;
+ padding: 0.3em 1em;
+}
+
+.cru-tab-bar-item:hover {
+ color: var(--cru-clickable-normal-color);
+}
+
+.cru-tab-bar-item.active {
+ color: var(--cru-push-button-text-color);
+ background-color: var(--cru-clickable-normal-color);
+ border-color: var(--cru-primary-color);
+}
+
+.cru-tab-bar-action-area {
+ margin-left: auto;
+}
diff --git a/FrontEnd/src/components/tab/TabBar.tsx b/FrontEnd/src/components/tab/TabBar.tsx
new file mode 100644
index 00000000..601f664d
--- /dev/null
+++ b/FrontEnd/src/components/tab/TabBar.tsx
@@ -0,0 +1,69 @@
+import { ReactNode } from "react";
+import { Link } from "react-router-dom";
+import classNames from "classnames";
+
+import { Text, ThemeColor, useC } from "../common";
+
+import "./TabBar.css";
+
+export interface Tab {
+ name: string;
+ text: Text;
+ link?: string;
+ onClick?: () => void;
+}
+
+export interface TabsProps {
+ activeTabName?: string;
+ tabs: Tab[];
+ color?: ThemeColor;
+ actions?: ReactNode;
+ dense?: boolean;
+ className?: string;
+}
+
+export default function TabBar(props: TabsProps) {
+ const { tabs, color, activeTabName, className, dense, actions } = props;
+
+ const c = useC();
+
+ return (
+ <div
+ className={classNames(
+ "cru-tab-bar",
+ dense && "dense",
+ `cru-clickable-${color ?? "primary"}`,
+ className,
+ )}
+ >
+ <div className="cru-tab-bar-tab-area">
+ {tabs.map((tab) => {
+ const { name, text, link, onClick } = tab;
+
+ const active = activeTabName === name;
+ const className = classNames("cru-tab-bar-item", active && "active");
+
+ if (link != null) {
+ return (
+ <Link
+ key={name}
+ to={link}
+ onClick={onClick}
+ className={className}
+ >
+ {c(text)}
+ </Link>
+ );
+ } else {
+ return (
+ <span key={name} onClick={onClick} className={className}>
+ {c(text)}
+ </span>
+ );
+ }
+ })}
+ </div>
+ <div className="cru-tab-bar-action-area">{actions}</div>
+ </div>
+ );
+}
diff --git a/FrontEnd/src/components/tab/TabPages.css b/FrontEnd/src/components/tab/TabPages.css
new file mode 100644
index 00000000..c07d042e
--- /dev/null
+++ b/FrontEnd/src/components/tab/TabPages.css
@@ -0,0 +1,3 @@
+.cru-tab-page-container {
+ padding-top: 0.5em;
+}
diff --git a/FrontEnd/src/components/tab/TabPages.tsx b/FrontEnd/src/components/tab/TabPages.tsx
new file mode 100644
index 00000000..ab45ffdf
--- /dev/null
+++ b/FrontEnd/src/components/tab/TabPages.tsx
@@ -0,0 +1,61 @@
+import { ReactNode, useState } from "react";
+import classNames from "classnames";
+
+import { Text, UiLogicError } from "../common";
+
+import Tabs from "./TabBar";
+
+import "./TabPages.css";
+
+interface TabPage {
+ name: string;
+ text: Text;
+ page: ReactNode;
+}
+
+interface TabPagesProps {
+ pages: TabPage[];
+ actions?: ReactNode;
+ dense?: boolean;
+ className?: string;
+ tabBarClassName?: string;
+ pageContainerClassName?: string;
+}
+
+export default function TabPages({
+ pages,
+ actions,
+ dense,
+ className,
+ tabBarClassName,
+ pageContainerClassName,
+}: TabPagesProps) {
+ const [tab, setTab] = useState<string>(pages[0].name);
+
+ const currentPage = pages.find((p) => p.name === tab);
+
+ if (currentPage == null) throw new UiLogicError();
+
+ return (
+ <div className={className}>
+ <Tabs
+ tabs={pages.map((page) => ({
+ name: page.name,
+ text: page.text,
+ onClick: () => {
+ setTab(page.name);
+ },
+ }))}
+ dense={dense}
+ activeTabName={tab}
+ className={tabBarClassName}
+ actions={actions}
+ />
+ <div
+ className={classNames("cru-tab-page-container", pageContainerClassName)}
+ >
+ {currentPage.page}
+ </div>
+ </div>
+ );
+}
diff --git a/FrontEnd/src/components/tab/index.ts b/FrontEnd/src/components/tab/index.ts
new file mode 100644
index 00000000..43d545cc
--- /dev/null
+++ b/FrontEnd/src/components/tab/index.ts
@@ -0,0 +1,2 @@
+export { default as TabBar } from "./TabBar";
+export { default as TabPages } from "./TabPages";
diff --git a/FrontEnd/src/components/theme.css b/FrontEnd/src/components/theme.css
new file mode 100644
index 00000000..68dd780f
--- /dev/null
+++ b/FrontEnd/src/components/theme.css
@@ -0,0 +1,201 @@
+:root {
+ --cru-default-font-family: 'Segoe UI', 'DengXian', 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%);
+ --cru-warn-color: #e4a700;
+}
+
+.cru-theme-primary {
+ --cru-theme-color: var(--cru-primary-color);
+}
+
+.cru-theme-secondary {
+ --cru-theme-color: var(--cru-secondary-color);
+}
+
+.cru-theme-create {
+ --cru-theme-color: var(--cru-create-color);
+}
+
+.cru-theme-danger {
+ --cru-theme-color: var(--cru-danger-color);
+}
+
+/* common colors */
+:root {
+ --cru-background-color: hsl(0 0% 100%);
+ --cru-container-background-color: hsl(0 0% 97%);
+ --cru-text-major-color: hsl(0 0% 0%);
+ --cru-text-minor-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-major-color: hsl(0 0% 100%);
+ --cru-text-minor-color: hsl(0 0% 85%);
+ }
+}
+
+:root {
+ --cru-body-background-color: var(--cru-background-color);
+}
+
+/* dialog color */
+
+:root {
+ --cru-dialog-overlay-color: hsl(0 0% 100%);
+ --cru-dialog-container-background-color: hsl(0 0% 100%);
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --cru-dialog-overlay-color: hsl(0 0% 0%);
+ --cru-dialog-container-background-color: hsl(0 0% 0%);
+ }
+}
+
+/* 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-light-normal-color: hsl(0 0% 100%);
+ --cru-clickable-light-hover-color: hsl(0 0% 92%);
+ --cru-clickable-light-focus-color: hsl(0 0% 92%);
+ --cru-clickable-light-active-color: hsl(0 0% 88%);
+ --cru-clickable-minor-normal-color: hsl(0 0% 30%);
+ --cru-clickable-minor-hover-color: hsl(0 0% 40%);
+ --cru-clickable-minor-focus-color: hsl(0 0% 40%);
+ --cru-clickable-minor-active-color: hsl(0 0% 45%);
+ --cru-clickable-disabled-color: hsl(0 0% 50%);
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --cru-clickable-minor-normal-color: hsl(0 0% 74%);
+ --cru-clickable-minor-hover-color: hsl(0 0% 82%);
+ --cru-clickable-minor-focus-color: hsl(0 0% 82%);
+ --cru-clickable-minor-active-color: hsl(0 0% 90%);
+ --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);
+}
+
+.cru-clickable-light {
+ --cru-clickable-normal-color: var(--cru-clickable-light-normal-color);
+ --cru-clickable-hover-color: var(--cru-clickable-light-hover-color);
+ --cru-clickable-focus-color: var(--cru-clickable-light-focus-color);
+ --cru-clickable-active-color: var(--cru-clickable-light-active-color);
+}
+
+.cru-clickable-minor {
+ --cru-clickable-normal-color: var(--cru-clickable-minor-normal-color);
+ --cru-clickable-hover-color: var(--cru-clickable-minor-hover-color);
+ --cru-clickable-focus-color: var(--cru-clickable-minor-focus-color);
+ --cru-clickable-active-color: var(--cru-clickable-minor-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)
+}
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}
+ />
+ );
+}