diff options
Diffstat (limited to 'FrontEnd/src/components')
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} + /> + ); +} |