From f5dfd52f6efece2f4cad227044ecf4dd66301bbc Mon Sep 17 00:00:00 2001 From: crupest Date: Sat, 26 Aug 2023 21:36:58 +0800 Subject: ... --- FrontEnd/src/components/AppBar.css | 87 ++++ FrontEnd/src/components/AppBar.tsx | 98 +++++ FrontEnd/src/components/BlobImage.tsx | 26 ++ FrontEnd/src/components/Card.css | 20 + FrontEnd/src/components/Card.tsx | 38 ++ FrontEnd/src/components/Icon.css | 3 + FrontEnd/src/components/Icon.tsx | 30 ++ FrontEnd/src/components/ImageCropper.css | 38 ++ FrontEnd/src/components/ImageCropper.tsx | 312 ++++++++++++++ FrontEnd/src/components/LoadFailReload.tsx | 37 ++ FrontEnd/src/components/LoadingPage.tsx | 13 + FrontEnd/src/components/Page.tsx | 15 + FrontEnd/src/components/SearchInput.css | 8 + FrontEnd/src/components/SearchInput.tsx | 50 +++ FrontEnd/src/components/Skeleton.css | 14 + FrontEnd/src/components/Skeleton.tsx | 32 ++ FrontEnd/src/components/Spinner.css | 13 + FrontEnd/src/components/Spinner.tsx | 36 ++ FrontEnd/src/components/TimelineLogo.tsx | 27 ++ FrontEnd/src/components/alert/AlertHost.tsx | 113 +++++ FrontEnd/src/components/alert/alert.css | 33 ++ FrontEnd/src/components/breakpoints.ts | 3 + FrontEnd/src/components/button/Button.css | 64 +++ FrontEnd/src/components/button/Button.tsx | 46 ++ FrontEnd/src/components/button/ButtonRow.css | 0 FrontEnd/src/components/button/ButtonRow.tsx | 62 +++ FrontEnd/src/components/button/ButtonRowV2.tsx | 143 +++++++ FrontEnd/src/components/button/FlatButton.css | 27 ++ FrontEnd/src/components/button/FlatButton.tsx | 36 ++ FrontEnd/src/components/button/IconButton.css | 30 ++ FrontEnd/src/components/button/IconButton.tsx | 30 ++ FrontEnd/src/components/button/LoadingButton.css | 13 + FrontEnd/src/components/button/LoadingButton.tsx | 40 ++ FrontEnd/src/components/button/index.tsx | 15 + FrontEnd/src/components/common.ts | 14 + FrontEnd/src/components/dialog/ConfirmDialog.css | 0 FrontEnd/src/components/dialog/ConfirmDialog.tsx | 59 +++ FrontEnd/src/components/dialog/Dialog.css | 60 +++ FrontEnd/src/components/dialog/Dialog.tsx | 63 +++ FrontEnd/src/components/dialog/DialogContainer.css | 20 + FrontEnd/src/components/dialog/DialogContainer.tsx | 95 +++++ FrontEnd/src/components/dialog/FullPageDialog.css | 44 ++ FrontEnd/src/components/dialog/FullPageDialog.tsx | 53 +++ FrontEnd/src/components/dialog/OperationDialog.css | 8 + FrontEnd/src/components/dialog/OperationDialog.tsx | 230 ++++++++++ FrontEnd/src/components/dialog/index.ts | 64 +++ FrontEnd/src/components/hooks.ts | 14 + FrontEnd/src/components/index.css | 100 +++++ FrontEnd/src/components/input/InputGroup.css | 54 +++ FrontEnd/src/components/input/InputGroup.tsx | 463 +++++++++++++++++++++ FrontEnd/src/components/input/index.ts | 11 + FrontEnd/src/components/list/ListContainer.css | 4 + FrontEnd/src/components/list/ListContainer.tsx | 23 + FrontEnd/src/components/list/ListItemContainer.css | 3 + FrontEnd/src/components/list/ListItemContainer.tsx | 23 + FrontEnd/src/components/list/index.ts | 4 + FrontEnd/src/components/menu/Menu.css | 36 ++ FrontEnd/src/components/menu/Menu.tsx | 62 +++ FrontEnd/src/components/menu/PopupMenu.css | 7 + FrontEnd/src/components/menu/PopupMenu.tsx | 73 ++++ FrontEnd/src/components/tab/TabPages.tsx | 71 ++++ FrontEnd/src/components/tab/Tabs.css | 33 ++ FrontEnd/src/components/tab/Tabs.tsx | 62 +++ FrontEnd/src/components/theme-color.css | 173 ++++++++ FrontEnd/src/components/theme.css | 146 +++++++ FrontEnd/src/components/user/UserAvatar.tsx | 22 + 66 files changed, 3646 insertions(+) create mode 100644 FrontEnd/src/components/AppBar.css create mode 100644 FrontEnd/src/components/AppBar.tsx create mode 100644 FrontEnd/src/components/BlobImage.tsx create mode 100644 FrontEnd/src/components/Card.css create mode 100644 FrontEnd/src/components/Card.tsx create mode 100644 FrontEnd/src/components/Icon.css create mode 100644 FrontEnd/src/components/Icon.tsx create mode 100644 FrontEnd/src/components/ImageCropper.css create mode 100644 FrontEnd/src/components/ImageCropper.tsx create mode 100644 FrontEnd/src/components/LoadFailReload.tsx create mode 100644 FrontEnd/src/components/LoadingPage.tsx create mode 100644 FrontEnd/src/components/Page.tsx create mode 100644 FrontEnd/src/components/SearchInput.css create mode 100644 FrontEnd/src/components/SearchInput.tsx create mode 100644 FrontEnd/src/components/Skeleton.css create mode 100644 FrontEnd/src/components/Skeleton.tsx create mode 100644 FrontEnd/src/components/Spinner.css create mode 100644 FrontEnd/src/components/Spinner.tsx create mode 100644 FrontEnd/src/components/TimelineLogo.tsx create mode 100644 FrontEnd/src/components/alert/AlertHost.tsx create mode 100644 FrontEnd/src/components/alert/alert.css create mode 100644 FrontEnd/src/components/breakpoints.ts create mode 100644 FrontEnd/src/components/button/Button.css create mode 100644 FrontEnd/src/components/button/Button.tsx create mode 100644 FrontEnd/src/components/button/ButtonRow.css create mode 100644 FrontEnd/src/components/button/ButtonRow.tsx create mode 100644 FrontEnd/src/components/button/ButtonRowV2.tsx create mode 100644 FrontEnd/src/components/button/FlatButton.css create mode 100644 FrontEnd/src/components/button/FlatButton.tsx create mode 100644 FrontEnd/src/components/button/IconButton.css create mode 100644 FrontEnd/src/components/button/IconButton.tsx create mode 100644 FrontEnd/src/components/button/LoadingButton.css create mode 100644 FrontEnd/src/components/button/LoadingButton.tsx create mode 100644 FrontEnd/src/components/button/index.tsx create mode 100644 FrontEnd/src/components/common.ts create mode 100644 FrontEnd/src/components/dialog/ConfirmDialog.css create mode 100644 FrontEnd/src/components/dialog/ConfirmDialog.tsx create mode 100644 FrontEnd/src/components/dialog/Dialog.css create mode 100644 FrontEnd/src/components/dialog/Dialog.tsx create mode 100644 FrontEnd/src/components/dialog/DialogContainer.css create mode 100644 FrontEnd/src/components/dialog/DialogContainer.tsx create mode 100644 FrontEnd/src/components/dialog/FullPageDialog.css create mode 100644 FrontEnd/src/components/dialog/FullPageDialog.tsx create mode 100644 FrontEnd/src/components/dialog/OperationDialog.css create mode 100644 FrontEnd/src/components/dialog/OperationDialog.tsx create mode 100644 FrontEnd/src/components/dialog/index.ts create mode 100644 FrontEnd/src/components/hooks.ts create mode 100644 FrontEnd/src/components/index.css create mode 100644 FrontEnd/src/components/input/InputGroup.css create mode 100644 FrontEnd/src/components/input/InputGroup.tsx create mode 100644 FrontEnd/src/components/input/index.ts create mode 100644 FrontEnd/src/components/list/ListContainer.css create mode 100644 FrontEnd/src/components/list/ListContainer.tsx create mode 100644 FrontEnd/src/components/list/ListItemContainer.css create mode 100644 FrontEnd/src/components/list/ListItemContainer.tsx create mode 100644 FrontEnd/src/components/list/index.ts create mode 100644 FrontEnd/src/components/menu/Menu.css create mode 100644 FrontEnd/src/components/menu/Menu.tsx create mode 100644 FrontEnd/src/components/menu/PopupMenu.css create mode 100644 FrontEnd/src/components/menu/PopupMenu.tsx create mode 100644 FrontEnd/src/components/tab/TabPages.tsx create mode 100644 FrontEnd/src/components/tab/Tabs.css create mode 100644 FrontEnd/src/components/tab/Tabs.tsx create mode 100644 FrontEnd/src/components/theme-color.css create mode 100644 FrontEnd/src/components/theme.css create mode 100644 FrontEnd/src/components/user/UserAvatar.tsx (limited to 'FrontEnd/src/components') diff --git a/FrontEnd/src/components/AppBar.css b/FrontEnd/src/components/AppBar.css new file mode 100644 index 00000000..a0d975b5 --- /dev/null +++ b/FrontEnd/src/components/AppBar.css @@ -0,0 +1,87 @@ +.app-bar { + height: 56px; + position: fixed; + z-index: 1030; + top: 0; + left: 0; + right: 0; + background-color: var(--cru-primary-color); +} + +.app-bar { + display: flex; +} + +.app-bar .app-bar-brand { + display: flex; + align-items: center; +} + +.app-bar .app-bar-brand-icon { + height: 2em; +} + +.app-bar .app-bar-user-area { + display: flex; + margin-left: auto; +} + +.app-bar a { + background-color: var(--cru-primary-color); + color: var(--cru-push-button-text-color); + text-decoration: none; + display: flex; + align-items: center; + padding: 0 1em; + transition: all 0.5s; +} + +.app-bar a:hover { + background-color: var(--cru-clickable-primary-hover-color); +} + +.app-bar a:focus { + background-color: var(--cru-clickable-primary-focus-color); +} + +.app-bar a:active { + background-color: var(--cru-clickable-primary-active-color); +} + +/* the current page */ +.app-bar a.active { + background-color: var(--cru-clickable-primary-focus-color); +} + +.app-bar .app-bar-avatar img { + width: 45px; + height: 45px; + background-color: white; + border-radius: 50%; +} + +.app-bar.desktop .app-bar-link-area { + display: flex; +} + +.app-bar.mobile .app-bar-link-area { + position: absolute; + z-index: -1; + top: 56px; + left: 0; + right: 0; + transition: transform 0.5s; +} + +.app-bar.mobile a { + height: 56px; +} + +.app-bar.mobile.collapse .app-bar-link-area { + transform: translateY(-100%); +} + +.app-bar .toggler { + font-size: 2em; + margin-right: 0.5em; +} \ No newline at end of file diff --git a/FrontEnd/src/components/AppBar.tsx b/FrontEnd/src/components/AppBar.tsx new file mode 100644 index 00000000..da3a946f --- /dev/null +++ b/FrontEnd/src/components/AppBar.tsx @@ -0,0 +1,98 @@ +import { useState } from "react"; +import classnames from "classnames"; +import { Link, NavLink } from "react-router-dom"; + +import { I18nText, useC, useMobile } from "./common"; +import { useUser } from "~src/services/user"; + +import TimelineLogo from "./TimelineLogo"; +import { IconButton } from "./button"; +import UserAvatar from "./user/UserAvatar"; + +import "./AppBar.css"; + +function AppBarNavLink({ + link, + className, + label, + onClick, + children, +}: { + link: string; + className?: string; + label?: I18nText; + onClick?: () => void; + children?: React.ReactNode; +}) { + if (label != null && children != null) { + throw new Error("AppBarNavLink: label and children cannot be both set"); + } + + const c = useC(); + + return ( + classnames(className, isActive && "active")} + onClick={onClick} + > + {children != null ? children : c(label)} + + ); +} + +export default function AppBar() { + const isMobile = useMobile(); + + const [isCollapse, setIsCollapse] = useState(true); + const collapse = isMobile ? () => setIsCollapse(true) : undefined; + const toggleCollapse = () => setIsCollapse(!isCollapse); + + const user = useUser(); + const hasAdministrationPermission = user && user.hasAdministrationPermission; + + return ( + + ); +} diff --git a/FrontEnd/src/components/BlobImage.tsx b/FrontEnd/src/components/BlobImage.tsx new file mode 100644 index 00000000..259c2210 --- /dev/null +++ b/FrontEnd/src/components/BlobImage.tsx @@ -0,0 +1,26 @@ +import { ComponentPropsWithoutRef, useState, useEffect } from "react"; + +type BlobImageProps = Omit, "src"> & { + imgRef?: React.Ref; + src?: Blob | string | null; +}; + +export default function BlobImage(props: BlobImageProps) { + const { imgRef, src, ...otherProps } = props; + + const [url, setUrl] = useState(undefined); + + useEffect(() => { + if (src instanceof Blob) { + const url = URL.createObjectURL(src); + setUrl(url); + return () => { + URL.revokeObjectURL(url); + }; + } else { + setUrl(src); + } + }, [src]); + + return ; +} diff --git a/FrontEnd/src/components/Card.css b/FrontEnd/src/components/Card.css new file mode 100644 index 00000000..6d655eb9 --- /dev/null +++ b/FrontEnd/src/components/Card.css @@ -0,0 +1,20 @@ +.cru-card { + border-radius: var(--cru-card-border-radius); + transition: all 0.3s; +} + +.cru-card-background-none { + background-color: transparent; +} + +.cru-card-background-solid { + background-color: var(--cru-background-color); +} + +.cru-card-background-grayscale { + background-color: var(--cru-container-background-color); +} + +.cru-card-border-color { + border: 2px solid var(--cru-card-border-color); +} diff --git a/FrontEnd/src/components/Card.tsx b/FrontEnd/src/components/Card.tsx new file mode 100644 index 00000000..a8f0d3cc --- /dev/null +++ b/FrontEnd/src/components/Card.tsx @@ -0,0 +1,38 @@ +import { ComponentPropsWithoutRef, Ref } from "react"; +import classNames from "classnames"; + +import { ThemeColor } from "./common"; +import "./Card.css"; + +interface CardProps extends ComponentPropsWithoutRef<"div"> { + containerRef?: Ref; + color?: ThemeColor; + border?: "color" | "none"; + background?: "color" | "solid" | "grayscale" | "none"; +} + +export default function Card({ + color, + background, + border, + className, + children, + containerRef, + ...otherProps +}: CardProps) { + return ( +
+ {children} +
+ ); +} diff --git a/FrontEnd/src/components/Icon.css b/FrontEnd/src/components/Icon.css new file mode 100644 index 00000000..fe980d7b --- /dev/null +++ b/FrontEnd/src/components/Icon.css @@ -0,0 +1,3 @@ +.cru-icon { + font-size: 1.4rem; +} diff --git a/FrontEnd/src/components/Icon.tsx b/FrontEnd/src/components/Icon.tsx new file mode 100644 index 00000000..2ac3a7ca --- /dev/null +++ b/FrontEnd/src/components/Icon.tsx @@ -0,0 +1,30 @@ +import { ComponentPropsWithoutRef } from "react"; +import classNames from "classnames"; + +import { ThemeColor } from "./common"; + +import "./Icon.css"; + +interface IconButtonProps extends ComponentPropsWithoutRef<"i"> { + icon: string; + color?: ThemeColor | "on-surface"; + size?: string | number; +} + +export default function Icon(props: IconButtonProps) { + const { icon, color, size, style, className, ...otherProps } = props; + + const colorName = color === "on-surface" ? "surface-on" : color; + + return ( + + ); +} diff --git a/FrontEnd/src/components/ImageCropper.css b/FrontEnd/src/components/ImageCropper.css new file mode 100644 index 00000000..2c4d0a8c --- /dev/null +++ b/FrontEnd/src/components/ImageCropper.css @@ -0,0 +1,38 @@ +.image-cropper-container { + position: relative; + box-sizing: border-box; + user-select: none; +} + +.image-cropper-container img { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; +} + +.image-cropper-mask-container { + position: absolute; + left: 0; + top: 0; + right: 0; + bottom: 0; + overflow: hidden; +} + +.image-cropper-mask { + position: absolute; + box-shadow: 0 0 0 10000px rgba(255, 255, 255, 0.8); + touch-action: none; +} + +.image-cropper-handler { + position: absolute; + width: 26px; + height: 26px; + border: black solid 2px; + border-radius: 50%; + background: white; + touch-action: none; +} diff --git a/FrontEnd/src/components/ImageCropper.tsx b/FrontEnd/src/components/ImageCropper.tsx new file mode 100644 index 00000000..f23994e2 --- /dev/null +++ b/FrontEnd/src/components/ImageCropper.tsx @@ -0,0 +1,312 @@ +import * as React from "react"; +import classnames from "classnames"; + +import { UiLogicError } from "~src/common"; + +import "./ImageCropper.css"; +import BlobImage from "./BlobImage"; + +export interface Clip { + left: number; + top: number; + width: number; +} + +interface NormailizedClip extends Clip { + height: number; +} + +interface ImageInfo { + width: number; + height: number; + landscape: boolean; + ratio: number; + maxClipWidth: number; + maxClipHeight: number; +} + +interface ImageCropperSavedState { + clip: NormailizedClip; + x: number; + y: number; + pointerId: number; +} + +export interface ImageCropperProps { + clip: Clip | null; + image: string | Blob; + onChange: (clip: Clip) => void; + imageElementCallback?: (element: HTMLImageElement | null) => void; + className?: string; +} + +const ImageCropper = (props: ImageCropperProps): React.ReactElement => { + const { clip, image, onChange, imageElementCallback, className } = props; + + const [oldState, setOldState] = React.useState( + null, + ); + const [imageInfo, setImageInfo] = React.useState(null); + + const normalizeClip = (c: Clip | null | undefined): NormailizedClip => { + if (c == null) { + return { left: 0, top: 0, width: 0, height: 0 }; + } + + return { + left: c.left || 0, + top: c.top || 0, + width: c.width || 0, + height: imageInfo != null ? (c.width || 0) / imageInfo.ratio : 0, + }; + }; + + const c = normalizeClip(clip); + + const imgElementRef = React.useRef(null); + + const onImageRef = React.useCallback( + (e: HTMLImageElement | null) => { + imgElementRef.current = e; + if (imageElementCallback != null && e == null) { + imageElementCallback(null); + } + }, + [imageElementCallback], + ); + + const onImageLoad = React.useCallback( + (e: React.SyntheticEvent) => { + const img = e.currentTarget; + const landscape = img.naturalWidth >= img.naturalHeight; + + const info = { + width: img.naturalWidth, + height: img.naturalHeight, + landscape, + ratio: img.naturalHeight / img.naturalWidth, + maxClipWidth: landscape ? img.naturalHeight / img.naturalWidth : 1, + maxClipHeight: landscape ? 1 : img.naturalWidth / img.naturalHeight, + }; + setImageInfo(info); + onChange({ left: 0, top: 0, width: info.maxClipWidth }); + if (imageElementCallback != null) { + imageElementCallback(img); + } + }, + [onChange, imageElementCallback], + ); + + const onPointerDown = React.useCallback( + (e: React.PointerEvent) => { + if (oldState != null) return; + e.currentTarget.setPointerCapture(e.pointerId); + setOldState({ + x: e.clientX, + y: e.clientY, + clip: c, + pointerId: e.pointerId, + }); + }, + [oldState, c], + ); + + const onPointerUp = React.useCallback( + (e: React.PointerEvent) => { + if (oldState == null || oldState.pointerId !== e.pointerId) return; + e.currentTarget.releasePointerCapture(e.pointerId); + setOldState(null); + }, + [oldState], + ); + + const onPointerMove = React.useCallback( + (e: React.PointerEvent) => { + if (oldState == null) return; + + const oldClip = oldState.clip; + + const movement = { x: e.clientX - oldState.x, y: e.clientY - oldState.y }; + + const { current: imgElement } = imgElementRef; + + if (imgElement == null) throw new UiLogicError("Image element is null."); + + const moveRatio = { + x: movement.x / imgElement.width, + y: movement.y / imgElement.height, + }; + + const newRatio = { + x: oldClip.left + moveRatio.x, + y: oldClip.top + moveRatio.y, + }; + if (newRatio.x < 0) { + newRatio.x = 0; + } else if (newRatio.x > 1 - oldClip.width) { + newRatio.x = 1 - oldClip.width; + } + if (newRatio.y < 0) { + newRatio.y = 0; + } else if (newRatio.y > 1 - oldClip.height) { + newRatio.y = 1 - oldClip.height; + } + + onChange({ left: newRatio.x, top: newRatio.y, width: oldClip.width }); + }, + [oldState, onChange], + ); + + const onHandlerPointerMove = React.useCallback( + (e: React.PointerEvent) => { + if (oldState == null) return; + + const oldClip = oldState.clip; + + const movement = { x: e.clientX - oldState.x, y: e.clientY - oldState.y }; + + const ratio = imageInfo == null ? 1 : imageInfo.ratio; + + const { current: imgElement } = imgElementRef; + + if (imgElement == null) throw new UiLogicError("Image element is null."); + + const moveRatio = { + x: movement.x / imgElement.width, + y: movement.x / imgElement.width / ratio, + }; + + const newRatio = { + x: oldClip.width + moveRatio.x, + y: oldClip.height + moveRatio.y, + }; + + const maxRatio = { + x: Math.min(1 - oldClip.left, newRatio.x), + y: Math.min(1 - oldClip.top, newRatio.y), + }; + + const maxWidthRatio = Math.min(maxRatio.x, maxRatio.y * ratio); + + let newWidth; + if (newRatio.x < 0) { + newWidth = 0; + } else if (newRatio.x > maxWidthRatio) { + newWidth = maxWidthRatio; + } else { + newWidth = newRatio.x; + } + + onChange({ left: oldClip.left, top: oldClip.top, width: newWidth }); + }, + [imageInfo, oldState, onChange], + ); + + const toPercentage = (n: number): string => `${n}%`; + + // fuck!!! I just can't find a better way to implement this in pure css + const containerStyle: React.CSSProperties = (() => { + if (imageInfo == null) { + return { width: "100%", paddingTop: "100%", height: 0 }; + } else { + if (imageInfo.ratio > 1) { + return { + width: toPercentage(100 / imageInfo.ratio), + paddingTop: "100%", + height: 0, + }; + } else { + return { + width: "100%", + paddingTop: toPercentage(100 * imageInfo.ratio), + height: 0, + }; + } + } + })(); + + return ( +
+ +
+
+
+
+
+ ); +}; + +export default ImageCropper; + +export function applyClipToImage( + image: HTMLImageElement, + clip: Clip, + mimeType: string, +): Promise { + return new Promise((resolve, reject) => { + const naturalSize = { + width: image.naturalWidth, + height: image.naturalHeight, + }; + const clipArea = { + x: naturalSize.width * clip.left, + y: naturalSize.height * clip.top, + length: naturalSize.width * clip.width, + }; + + const canvas = document.createElement("canvas"); + canvas.width = clipArea.length; + canvas.height = clipArea.length; + const context = canvas.getContext("2d"); + + if (context == null) throw new Error("Failed to create context."); + + context.drawImage( + image, + clipArea.x, + clipArea.y, + clipArea.length, + clipArea.length, + 0, + 0, + clipArea.length, + clipArea.length, + ); + + canvas.toBlob((blob) => { + if (blob == null) { + reject(new Error("canvas.toBlob returns null")); + } else { + resolve(blob); + } + }, mimeType); + }); +} diff --git a/FrontEnd/src/components/LoadFailReload.tsx b/FrontEnd/src/components/LoadFailReload.tsx new file mode 100644 index 00000000..81ba1f67 --- /dev/null +++ b/FrontEnd/src/components/LoadFailReload.tsx @@ -0,0 +1,37 @@ +import * as React from "react"; +import { Trans } from "react-i18next"; + +export interface LoadFailReloadProps { + className?: string; + style?: React.CSSProperties; + onReload: () => void; +} + +const LoadFailReload: React.FC = ({ + onReload, + className, + style, +}) => { + return ( + + 0 + { + onReload(); + e.preventDefault(); + }} + > + 1 + + 2 + + ); +}; + +export default LoadFailReload; diff --git a/FrontEnd/src/components/LoadingPage.tsx b/FrontEnd/src/components/LoadingPage.tsx new file mode 100644 index 00000000..35ee1aa8 --- /dev/null +++ b/FrontEnd/src/components/LoadingPage.tsx @@ -0,0 +1,13 @@ +import * as React from "react"; + +import Spinner from "./Spinner"; + +const LoadingPage: React.FC = () => { + return ( +
+ +
+ ); +}; + +export default LoadingPage; diff --git a/FrontEnd/src/components/Page.tsx b/FrontEnd/src/components/Page.tsx new file mode 100644 index 00000000..86fdb2f5 --- /dev/null +++ b/FrontEnd/src/components/Page.tsx @@ -0,0 +1,15 @@ +import { ComponentPropsWithoutRef, Ref } from "react"; +import classNames from "classnames"; + +interface PageProps extends ComponentPropsWithoutRef<"div"> { + noTopPadding?: boolean; + pageRef?: Ref; +} + +export default function Page({ noTopPadding, pageRef, className, children }: PageProps) { + return ( +
+ {children} +
+ ); +} diff --git a/FrontEnd/src/components/SearchInput.css b/FrontEnd/src/components/SearchInput.css new file mode 100644 index 00000000..f0503016 --- /dev/null +++ b/FrontEnd/src/components/SearchInput.css @@ -0,0 +1,8 @@ +.cru-search-input { + display: flex; + flex-wrap: wrap; +} + +.cru-search-input-input { + width: 100%; +} diff --git a/FrontEnd/src/components/SearchInput.tsx b/FrontEnd/src/components/SearchInput.tsx new file mode 100644 index 00000000..e3216b86 --- /dev/null +++ b/FrontEnd/src/components/SearchInput.tsx @@ -0,0 +1,50 @@ +import classNames from "classnames"; + +import { useC, Text } from "./common"; +import LoadingButton from "./button/LoadingButton"; + +import "./SearchInput.css"; + +interface SearchInputProps { + value: string; + onChange: (value: string) => void; + onButtonClick: () => void; + loading?: boolean; + className?: string; + buttonText?: Text; +} + +export default function SearchInput({ + value, + onChange, + onButtonClick, + loading, + className, + buttonText, +}: SearchInputProps) { + const c = useC(); + + return ( +
+ { + const { value } = event.currentTarget; + onChange(value); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + onButtonClick(); + event.preventDefault(); + } + }} + /> + + + {c(buttonText ?? "search")} + +
+ ); +} diff --git a/FrontEnd/src/components/Skeleton.css b/FrontEnd/src/components/Skeleton.css new file mode 100644 index 00000000..a571eead --- /dev/null +++ b/FrontEnd/src/components/Skeleton.css @@ -0,0 +1,14 @@ +.cru-skeleton { + padding: 0 1em; +} + +.cru-skeleton-line { + height: 1em; + background-color: hsl(0, 0%, 90%); + margin: 0.7em 0; + border-radius: 0.2em; +} + +.cru-skeleton-line.last { + width: 50%; +} diff --git a/FrontEnd/src/components/Skeleton.tsx b/FrontEnd/src/components/Skeleton.tsx new file mode 100644 index 00000000..3b149db9 --- /dev/null +++ b/FrontEnd/src/components/Skeleton.tsx @@ -0,0 +1,32 @@ +import * as React from "react"; +import classnames from "classnames"; +import range from "lodash/range"; + +import "./Skeleton.css"; + +export interface SkeletonProps { + lineNumber?: number; + className?: string; + style?: React.CSSProperties; +} + +const Skeleton: React.FC = (props) => { + const { lineNumber: lineNumberProps, className, style } = props; + const lineNumber = lineNumberProps ?? 3; + + return ( +
+ {range(lineNumber).map((i) => ( +
+ ))} +
+ ); +}; + +export default Skeleton; diff --git a/FrontEnd/src/components/Spinner.css b/FrontEnd/src/components/Spinner.css new file mode 100644 index 00000000..a1de68d2 --- /dev/null +++ b/FrontEnd/src/components/Spinner.css @@ -0,0 +1,13 @@ +@keyframes cru-spinner-animation { + from { + transform: scale(0,0); + } +} + +.cru-spinner { + display: inline-block; + animation: cru-spinner-animation 0.5s infinite alternate; + background-color: currentColor; + border-radius: 50%; + transform-origin: center; +} diff --git a/FrontEnd/src/components/Spinner.tsx b/FrontEnd/src/components/Spinner.tsx new file mode 100644 index 00000000..ec0c2c35 --- /dev/null +++ b/FrontEnd/src/components/Spinner.tsx @@ -0,0 +1,36 @@ +import { CSSProperties } from "react"; +import classnames from "classnames"; + +import { ThemeColor } from "./common"; + +import "./Spinner.css"; + +export interface SpinnerProps { + size?: "sm" | "md" | "lg" | number | string; + color?: ThemeColor; + className?: string; + style?: CSSProperties; +} + +export default function Spinner(props: SpinnerProps) { + const { size, color, className, style } = props; + const calculatedSize = + size === "sm" + ? "18px" + : size === "md" + ? "30px" + : size === "lg" + ? "42px" + : typeof size === "number" + ? size + : size == null + ? "20px" + : size; + + return ( + + ); +} 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 { + color?: string; +} + +const TimelineLogo: React.FC = (props) => { + const { color, ...forwardProps } = props; + const coercedColor = color ?? "currentcolor"; + return ( + + + + + + ); +}; + +export default TimelineLogo; diff --git a/FrontEnd/src/components/alert/AlertHost.tsx b/FrontEnd/src/components/alert/AlertHost.tsx new file mode 100644 index 00000000..b234ac03 --- /dev/null +++ b/FrontEnd/src/components/alert/AlertHost.tsx @@ -0,0 +1,113 @@ +import * as React from "react"; +import without from "lodash/without"; +import { useTranslation } from "react-i18next"; +import classNames from "classnames"; + +import { alertService, AlertInfoEx, AlertInfo } from "~src/services/alert"; +import { convertI18nText } from "~src/common"; + +import IconButton from "../button/IconButton"; + +import "./alert.css"; + +interface AutoCloseAlertProps { + alert: AlertInfo; + close: () => void; +} + +export const AutoCloseAlert: React.FC = (props) => { + const { alert, close } = props; + const { dismissTime } = alert; + + const { t } = useTranslation(); + + const timerTag = React.useRef(null); + const closeHandler = React.useRef<(() => void) | null>(null); + + React.useEffect(() => { + closeHandler.current = close; + }, [close]); + + React.useEffect(() => { + const tag = + dismissTime === "never" + ? null + : typeof dismissTime === "number" + ? window.setTimeout(() => closeHandler.current?.(), dismissTime) + : window.setTimeout(() => closeHandler.current?.(), 5000); + timerTag.current = tag; + return () => { + if (tag != null) { + window.clearTimeout(tag); + } + }; + }, [dismissTime]); + + const cancelTimer = (): void => { + const { current: tag } = timerTag; + if (tag != null) { + window.clearTimeout(tag); + } + }; + + return ( +
+
+ {(() => { + const { message, customMessage } = alert; + if (customMessage != null) { + return customMessage; + } else { + return convertI18nText(message, t); + } + })()} +
+
+ +
+
+ ); +}; + +const AlertHost: React.FC = () => { + const [alerts, setAlerts] = React.useState([]); + + React.useEffect(() => { + const consume = (alert: AlertInfoEx): void => { + setAlerts((old) => [...old, alert]); + }; + + alertService.registerConsumer(consume); + return () => { + alertService.unregisterConsumer(consume); + }; + }, []); + + return ( +
+ {alerts.map((alert) => { + return ( + { + setAlerts((old) => without(old, alert)); + }} + /> + ); + })} +
+ ); +}; + +export default AlertHost; diff --git a/FrontEnd/src/components/alert/alert.css b/FrontEnd/src/components/alert/alert.css new file mode 100644 index 00000000..54c2b87f --- /dev/null +++ b/FrontEnd/src/components/alert/alert.css @@ -0,0 +1,33 @@ +.alert-container { + position: fixed; + z-index: 1040; +} + +.cru-alert { + border-radius: 5px; + border: var(--cru-key-color) 1px solid; + color: var(--cru-key-t-color); + background-color: var(--cru-key-b1-color); + + display: flex; + overflow: hidden; +} + +.cru-alert-content { + padding: 0.5em 2em; +} + +.cru-alert-close-button-container { + flex-shrink: 0; + margin-left: auto; + width: 2em; + text-align: center; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--cru-key-t-color); +} + +.cru-alert-close-button { + color: var(--cru-key-color); +} diff --git a/FrontEnd/src/components/breakpoints.ts b/FrontEnd/src/components/breakpoints.ts new file mode 100644 index 00000000..fb281610 --- /dev/null +++ b/FrontEnd/src/components/breakpoints.ts @@ -0,0 +1,3 @@ +export const breakpoints = { + sm: 576, +} as const; diff --git a/FrontEnd/src/components/button/Button.css b/FrontEnd/src/components/button/Button.css new file mode 100644 index 00000000..1da70f0e --- /dev/null +++ b/FrontEnd/src/components/button/Button.css @@ -0,0 +1,64 @@ +.cru-button { + font-size: 1rem; + padding: 0.4em 0.8em; + transition: all 0.3s; + border-radius: 0.2em; + border: 1px solid; + cursor: pointer; +} + +.cru-button:not(.outline) { + color: var(--cru-push-button-text-color); + background-color: var(--cru-clickable-normal-color); + border-color: var(--cru-clickable-normal-color); +} + +.cru-button:not(.outline):hover { + background-color: var(--cru-clickable-hover-color); + border-color: var(--cru-clickable-hover-color); +} + +.cru-button:not(.outline):focus { + background-color: var(--cru-clickable-focus-color); + border-color: var(--cru-clickable-focus-color); +} + +.cru-button:not(.outline):active { + background-color: var(--cru-clickable-active-color); + border-color: var(--cru-clickable-active-color); +} + +.cru-button:not(.outline):disabled { + color: var(--cru-push-button-disabled-text-color); + background-color: var(--cru-push-button-disabled-color); + border-color: var(--cru-push-button-disabled-color); + cursor: auto; +} + + +.cru-button.outline { + color: var(--cru-clickable-normal-color); + border-color: var(--cru-clickable-normal-color); + background-color: transparent; +} + +.cru-button.outline:hover { + color: var(--cru-clickable-hover-color); + border-color: var(--cru-clickable-hover-color); +} + +.cru-button.outline:focus { + color: var(--cru-clickable-focus-color); + border-color: var(--cru-clickable-focus-color); +} + +.cru-button.outline:active { + color: var(--cru-clickable-active-color); + border-color: var(--cru-clickable-active-color); +} + +.cru-button.outline:disabled { + color: var(--cru-clickable-disabled-color); + border-color: var(--cru-clickable-disabled-color); + cursor: auto; +} diff --git a/FrontEnd/src/components/button/Button.tsx b/FrontEnd/src/components/button/Button.tsx new file mode 100644 index 00000000..6c38e130 --- /dev/null +++ b/FrontEnd/src/components/button/Button.tsx @@ -0,0 +1,46 @@ +import { ComponentPropsWithoutRef, Ref } from "react"; +import classNames from "classnames"; + +import { Text, useC, ThemeColor } from "../common"; + +import "./Button.css"; + +interface ButtonProps extends ComponentPropsWithoutRef<"button"> { + color?: ThemeColor; + text?: Text; + outline?: boolean; + buttonRef?: Ref | 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 ( + + ); +} diff --git a/FrontEnd/src/components/button/ButtonRow.css b/FrontEnd/src/components/button/ButtonRow.css new file mode 100644 index 00000000..e69de29b 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; + } + | { + type: "flat"; + props: ComponentPropsWithoutRef; + } + | { + type: "icon"; + props: ComponentPropsWithoutRef; + } + | { type: "loading"; props: ComponentPropsWithoutRef } +) & { key: string | number }; + +interface ButtonRowProps { + className?: string; + containerRef?: Ref; + buttons: ButtonRowButton[]; + buttonsClassName?: string; +} + +export default function ButtonRow({ + className, + containerRef, + buttons, + buttonsClassName, +}: ButtonRowProps) { + return ( +
+ {buttons.map((button) => { + const { type, key, props } = button; + const newClassName = classNames(props.className, buttonsClassName); + switch (type) { + case "normal": + return
+ ); +} diff --git a/FrontEnd/src/components/button/ButtonRowV2.tsx b/FrontEnd/src/components/button/ButtonRowV2.tsx new file mode 100644 index 00000000..3467ad52 --- /dev/null +++ b/FrontEnd/src/components/button/ButtonRowV2.tsx @@ -0,0 +1,143 @@ +import { ComponentPropsWithoutRef, Ref } from "react"; +import classNames from "classnames"; + +import Button from "./Button"; +import FlatButton from "./FlatButton"; +import IconButton from "./IconButton"; +import LoadingButton from "./LoadingButton"; + +import "./ButtonRow.css"; +import { Text, ThemeColor } from "../common"; + +interface ButtonRowV2ButtonBase { + key: string | number; + action?: "primary" | "secondary"; + color?: ThemeColor; + disabled?: boolean; + onClick?: () => void; +} + +interface ButtonRowV2ButtonWithNoType extends ButtonRowV2ButtonBase { + type?: undefined | null; + text: Text; + outline?: boolean; + props?: ComponentPropsWithoutRef; +} + +interface ButtonRowV2NormalButton extends ButtonRowV2ButtonBase { + type: "normal"; + text: Text; + outline?: boolean; + props?: ComponentPropsWithoutRef; +} + +interface ButtonRowV2FlatButton extends ButtonRowV2ButtonBase { + type: "flat"; + text: Text; + props?: ComponentPropsWithoutRef; +} + +interface ButtonRowV2IconButton extends ButtonRowV2ButtonBase { + type: "icon"; + icon: string; + props?: ComponentPropsWithoutRef; +} + +interface ButtonRowV2LoadingButton extends ButtonRowV2ButtonBase { + type: "loading"; + text: Text; + loading?: boolean; + props?: ComponentPropsWithoutRef; +} + +type ButtonRowV2Button = + | ButtonRowV2ButtonWithNoType + | ButtonRowV2NormalButton + | ButtonRowV2FlatButton + | ButtonRowV2IconButton + | ButtonRowV2LoadingButton; + +interface ButtonRowV2Props { + className?: string; + containerRef?: Ref; + buttons: ButtonRowV2Button[]; + buttonsClassName?: string; +} + +export default function ButtonRowV2({ + className, + containerRef, + buttons, + buttonsClassName, +}: ButtonRowV2Props) { + return ( +
+ {buttons.map((button) => { + const { key, action, color, disabled, onClick } = button; + + const realAction = action ?? "primary"; + const realColor = + color ?? (realAction === "primary" ? "primary" : "secondary"); + + const commonProps = { key, color: realColor, disabled, onClick }; + const newClassName = classNames( + button.props?.className, + buttonsClassName, + ); + + switch (button.type) { + case null: + case undefined: + case "normal": { + const { text, outline, props } = button; + return ( +
+ ); +} diff --git a/FrontEnd/src/components/button/FlatButton.css b/FrontEnd/src/components/button/FlatButton.css new file mode 100644 index 00000000..2050946c --- /dev/null +++ b/FrontEnd/src/components/button/FlatButton.css @@ -0,0 +1,27 @@ +.cru-flat-button { + font-size: 1rem; + padding: 0.4em 0.8em; + transition: all 0.5s; + border-radius: 0.2em; + background-color: var(--cru-clickable-grayscale-normal-color); + border: 1px none; + color: var(--cru-clickable-normal-color); + cursor: pointer; +} + +.cru-flat-button:hover { + background-color: var(--cru-clickable-grayscale-hover-color); +} + +.cru-flat-button:focus { + background-color: var(--cru-clickable-grayscale-focus-color); +} + +.cru-flat-button:active { + background-color: var(--cru-clickable-grayscale-active-color); +} + +.cru-flat-button:disabled { + color: var(--cru-clickable-disabled-color); + cursor: auto; +} \ No newline at end of file diff --git a/FrontEnd/src/components/button/FlatButton.tsx b/FrontEnd/src/components/button/FlatButton.tsx new file mode 100644 index 00000000..9f074dd6 --- /dev/null +++ b/FrontEnd/src/components/button/FlatButton.tsx @@ -0,0 +1,36 @@ +import { ComponentPropsWithoutRef, Ref } from "react"; +import classNames from "classnames"; + +import { Text, useC, ThemeColor } from "../common"; + +import "./FlatButton.css"; + +interface FlatButtonProps extends ComponentPropsWithoutRef<"button"> { + color?: ThemeColor; + text?: Text; + buttonRef?: Ref | 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 ( + + ); +} diff --git a/FrontEnd/src/components/button/IconButton.css b/FrontEnd/src/components/button/IconButton.css new file mode 100644 index 00000000..a3747201 --- /dev/null +++ b/FrontEnd/src/components/button/IconButton.css @@ -0,0 +1,30 @@ +.cru-icon-button { + color: var(--cru-clickable-normal-color); + font-size: 1.4rem; + background: none; + border: none; + transition: all 0.5s; + cursor: pointer; + user-select: none; +} + +.cru-icon-button:hover { + color: var(--cru-clickable-hover-color); +} + +.cru-icon-button:focus { + color: var(--cru-clickable-focus-color); +} + +.cru-icon-button:active { + color: var(--cru-clickable-active-color); +} + +.cru-flat-button:disabled { + color: var(--cru-clickable-disabled-color); + cursor: auto; +} + +.cru-icon-button.large { + font-size: 1.6rem; +} diff --git a/FrontEnd/src/components/button/IconButton.tsx b/FrontEnd/src/components/button/IconButton.tsx new file mode 100644 index 00000000..95c58887 --- /dev/null +++ b/FrontEnd/src/components/button/IconButton.tsx @@ -0,0 +1,30 @@ +import { ComponentPropsWithoutRef } from "react"; +import classNames from "classnames"; + +import { ThemeColor } from "../common"; + +import "./IconButton.css"; + +interface IconButtonProps extends ComponentPropsWithoutRef<"i"> { + icon: string; + color?: ThemeColor | "grayscale"; + large?: boolean; + disabled?: boolean; // TODO: Not implemented +} + +export default function IconButton(props: IconButtonProps) { + const { icon, color, className, large, ...otherProps } = props; + + return ( + + ); +} diff --git a/FrontEnd/src/components/button/index.tsx b/FrontEnd/src/components/button/index.tsx new file mode 100644 index 00000000..b5aa5470 --- /dev/null +++ b/FrontEnd/src/components/button/index.tsx @@ -0,0 +1,15 @@ +import Button from "./Button"; +import FlatButton from "./FlatButton"; +import IconButton from "./IconButton"; +import LoadingButton from "./LoadingButton"; +import ButtonRow from "./ButtonRow"; +import ButtonRowV2 from "./ButtonRowV2"; + +export { + Button, + FlatButton, + IconButton, + LoadingButton, + ButtonRow, + ButtonRowV2, +}; diff --git a/FrontEnd/src/components/common.ts b/FrontEnd/src/components/common.ts new file mode 100644 index 00000000..e6f7319f --- /dev/null +++ b/FrontEnd/src/components/common.ts @@ -0,0 +1,14 @@ +export type { Text, I18nText } from "~src/common"; +export { UiLogicError, c, convertI18nText, useC } from "~src/common"; + +export const themeColors = [ + "primary", + "secondary", + "danger", + "create", +] as const; + +export type ThemeColor = (typeof themeColors)[number]; + +export { breakpoints } from "./breakpoints"; +export { useMobile } from "./hooks"; diff --git a/FrontEnd/src/components/dialog/ConfirmDialog.css b/FrontEnd/src/components/dialog/ConfirmDialog.css new file mode 100644 index 00000000..e69de29b diff --git a/FrontEnd/src/components/dialog/ConfirmDialog.tsx b/FrontEnd/src/components/dialog/ConfirmDialog.tsx new file mode 100644 index 00000000..26939c9b --- /dev/null +++ b/FrontEnd/src/components/dialog/ConfirmDialog.tsx @@ -0,0 +1,59 @@ +import { useC, Text, ThemeColor } from "../common"; + +import Dialog from "./Dialog"; +import DialogContainer from "./DialogContainer"; + +export default function ConfirmDialog({ + open, + onClose, + onConfirm, + title, + body, + color, + bodyColor, +}: { + open: boolean; + onClose: () => void; + onConfirm: () => void; + title: Text; + body: Text; + color?: ThemeColor; + bodyColor?: ThemeColor; +}) { + const c = useC(); + + return ( + + { + onConfirm(); + onClose(); + }, + }, + }, + ]} + > +
{c(body)}
+
+
+ ); +} diff --git a/FrontEnd/src/components/dialog/Dialog.css b/FrontEnd/src/components/dialog/Dialog.css new file mode 100644 index 00000000..e4c61440 --- /dev/null +++ b/FrontEnd/src/components/dialog/Dialog.css @@ -0,0 +1,60 @@ +.cru-dialog-overlay { + position: fixed; + z-index: 1040; + left: 0; + top: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + overflow: auto; +} + +.cru-dialog-background { + position: absolute; + z-index: -1; + left: 0; + right: 0; + top: 0; + bottom: 0; + background-color: var(--cru-surface-dim-color); + opacity: 0.8; +} + +.cru-dialog-container { + max-width: 100%; + min-width: 30vw; + + margin: 2em auto; + + border: var(--cru-key-container-color) 1px solid; + border-radius: 5px; + padding: 1.5em; + background-color: var(--cru-surface-color); +} + +@media (min-width: 576px) { + .cru-dialog-container { + max-width: 800px; + } +} + +.cru-dialog-enter .cru-dialog-container { + transform: scale(0, 0); + opacity: 0; + transform-origin: center; +} + +.cru-dialog-enter-active .cru-dialog-container { + transform: scale(1, 1); + opacity: 1; + transition: transform 0.3s, opacity 0.3s; + transform-origin: center; +} + +.cru-dialog-exit-active .cru-dialog-container { + transition: transform 0.3s, opacity 0.3s; + transform: scale(0, 0); + opacity: 0; + transform-origin: center; +} \ No newline at end of file diff --git a/FrontEnd/src/components/dialog/Dialog.tsx b/FrontEnd/src/components/dialog/Dialog.tsx new file mode 100644 index 00000000..2ff7bea8 --- /dev/null +++ b/FrontEnd/src/components/dialog/Dialog.tsx @@ -0,0 +1,63 @@ +import { ReactNode, useRef } from "react"; +import ReactDOM from "react-dom"; +import { CSSTransition } from "react-transition-group"; +import classNames from "classnames"; + +import { ThemeColor } from "../common"; + +import "./Dialog.css"; + +const optionalPortalElement = document.getElementById("portal"); +if (optionalPortalElement == null) { + throw new Error("Portal element not found"); +} +const portalElement = optionalPortalElement; + +interface DialogProps { + open: boolean; + onClose: () => void; + color?: ThemeColor; + children?: ReactNode; + disableCloseOnClickOnOverlay?: boolean; +} + +export default function Dialog({ + open, + onClose, + color, + children, + disableCloseOnClickOnOverlay, +}: DialogProps) { + color = color ?? "primary"; + + const nodeRef = useRef(null); + + return ReactDOM.createPortal( + +
+
{ + onClose(); + } + } + /> +
{children}
+
+ , + portalElement, + ); +} diff --git a/FrontEnd/src/components/dialog/DialogContainer.css b/FrontEnd/src/components/dialog/DialogContainer.css new file mode 100644 index 00000000..fbb18e0d --- /dev/null +++ b/FrontEnd/src/components/dialog/DialogContainer.css @@ -0,0 +1,20 @@ +.cru-dialog-container-title { + font-size: 1.2em; + font-weight: bold; + color: var(--cru-key-color); + margin-bottom: 0.5em; +} + + +.cru-dialog-container-hr { + margin: 1em 0; +} + +.cru-dialog-container-button-row { + display: flex; + justify-content: flex-end; +} + +.cru-dialog-container-button { + margin-left: 1em; +} \ No newline at end of file diff --git a/FrontEnd/src/components/dialog/DialogContainer.tsx b/FrontEnd/src/components/dialog/DialogContainer.tsx new file mode 100644 index 00000000..afee2669 --- /dev/null +++ b/FrontEnd/src/components/dialog/DialogContainer.tsx @@ -0,0 +1,95 @@ +import { ComponentProps, Ref, ReactNode } from "react"; +import classNames from "classnames"; + +import { ThemeColor, Text, useC } from "../common"; +import { ButtonRow, ButtonRowV2 } from "../button"; + +import "./DialogContainer.css"; + +interface DialogContainerBaseProps { + className?: string; + title: Text; + titleColor?: ThemeColor; + titleClassName?: string; + titleRef?: Ref; + bodyContainerClassName?: string; + bodyContainerRef?: Ref; + buttonsClassName?: string; + buttonsContainerRef?: ComponentProps["containerRef"]; + children: ReactNode; +} + +interface DialogContainerWithButtonsProps extends DialogContainerBaseProps { + buttons: ComponentProps["buttons"]; +} + +interface DialogContainerWithButtonsV2Props extends DialogContainerBaseProps { + buttonsV2: ComponentProps["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 ( +
+
+ {c(title)} +
+
+
+ {children} +
+
+ {"buttons" in props ? ( + + ) : ( + + )} +
+ ); +} diff --git a/FrontEnd/src/components/dialog/FullPageDialog.css b/FrontEnd/src/components/dialog/FullPageDialog.css new file mode 100644 index 00000000..2f1fc636 --- /dev/null +++ b/FrontEnd/src/components/dialog/FullPageDialog.css @@ -0,0 +1,44 @@ +.cru-full-page { + position: fixed; + z-index: 1030; + left: 0; + top: 0; + right: 0; + bottom: 0; + background-color: white; + padding-top: 56px; +} + +.cru-full-page-top-bar { + height: 56px; + position: absolute; + top: 0; + left: 0; + right: 0; + z-index: 1; + background-color: var(--cru-primary-color); + display: flex; + align-items: center; +} + +.cru-full-page-content-container { + overflow: scroll; +} + +.cru-full-page-back-button { + color: var(--cru-primary-t-color); +} + +.cru-full-page-enter { + transform: translate(100%, 0); +} + +.cru-full-page-enter-active { + transform: none; + transition: transform 0.3s; +} + +.cru-full-page-exit-active { + transition: transform 0.3s; + transform: translate(100%, 0); +} diff --git a/FrontEnd/src/components/dialog/FullPageDialog.tsx b/FrontEnd/src/components/dialog/FullPageDialog.tsx new file mode 100644 index 00000000..6368fc0a --- /dev/null +++ b/FrontEnd/src/components/dialog/FullPageDialog.tsx @@ -0,0 +1,53 @@ +import * as React from "react"; +import { createPortal } from "react-dom"; +import classnames from "classnames"; +import { CSSTransition } from "react-transition-group"; + +import "./FullPageDialog.css"; +import IconButton from "../button/IconButton"; + +export interface FullPageDialogProps { + show: boolean; + onBack: () => void; + contentContainerClassName?: string; + children: React.ReactNode; +} + +const FullPageDialog: React.FC = ({ + show, + onBack, + children, + contentContainerClassName, +}) => { + return createPortal( + +
+
+ +
+
+ {children} +
+
+
, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + document.getElementById("portal")! + ); +}; + +export default FullPageDialog; diff --git a/FrontEnd/src/components/dialog/OperationDialog.css b/FrontEnd/src/components/dialog/OperationDialog.css new file mode 100644 index 00000000..f4b7237e --- /dev/null +++ b/FrontEnd/src/components/dialog/OperationDialog.css @@ -0,0 +1,8 @@ +.cru-operation-dialog-prompt { + color: var(--cru-surface-on-color); +} + +.cru-operation-dialog-input-group { + display: block; + margin: 0.5em 0; +} diff --git a/FrontEnd/src/components/dialog/OperationDialog.tsx b/FrontEnd/src/components/dialog/OperationDialog.tsx new file mode 100644 index 00000000..e5db7f4f --- /dev/null +++ b/FrontEnd/src/components/dialog/OperationDialog.tsx @@ -0,0 +1,230 @@ +import { useState, ReactNode, ComponentProps } from "react"; +import classNames from "classnames"; + +import { useC, Text, ThemeColor } from "../common"; + +import { + useInputs, + InputGroup, + Initializer as InputInitializer, + InputValueDict, + InputErrorDict, + InputConfirmValueDict, +} from "../input"; +import Dialog from "./Dialog"; +import DialogContainer from "./DialogContainer"; +import { ButtonRow } from "../button"; + +import "./OperationDialog.css"; + +export type { InputInitializer, InputValueDict, InputErrorDict }; + +interface OperationDialogPromptProps { + message?: Text; + customMessage?: Text; + customMessageNode?: ReactNode; + className?: string; +} + +function OperationDialogPrompt(props: OperationDialogPromptProps) { + const { message, customMessage, customMessageNode, className } = props; + + const c = useC(); + + return ( +
+ {message &&

{c(message)}

} + {customMessageNode ?? (customMessage != null ? c(customMessage) : null)} +
+ ); +} + +export interface OperationDialogProps { + open: boolean; + onClose: () => void; + + color?: ThemeColor; + inputColor?: ThemeColor; + title: Text; + inputPrompt?: Text; + inputPromptNode?: ReactNode; + successPrompt?: (data: TData) => Text; + successPromptNode?: (data: TData) => ReactNode; + failurePrompt?: (error: unknown) => Text; + failurePromptNode?: (error: unknown) => ReactNode; + + inputs: InputInitializer; + + onProcess: (inputs: InputConfirmValueDict) => Promise; + onSuccessAndClose?: (data: TData) => void; +} + +function OperationDialog(props: OperationDialogProps) { + const { + open, + onClose, + color, + inputColor, + title, + inputPrompt, + inputPromptNode, + successPrompt, + successPromptNode, + failurePrompt, + failurePromptNode, + inputs, + onProcess, + onSuccessAndClose, + } = props; + + if (process.env.NODE_ENV === "development") { + if (inputPrompt && inputPromptNode) { + console.log("InputPrompt and inputPromptNode are both set."); + } + if (successPrompt && successPromptNode) { + console.log("SuccessPrompt and successPromptNode are both set."); + } + if (failurePrompt && failurePromptNode) { + console.log("FailurePrompt and failurePromptNode are both set."); + } + } + + type Step = + | { type: "input" } + | { type: "process" } + | { + type: "success"; + data: TData; + } + | { + type: "failure"; + data: unknown; + }; + + const [step, setStep] = useState({ type: "input" }); + + const { inputGroupProps, hasErrorAndDirty, setAllDisabled, confirm } = + useInputs({ + init: inputs, + }); + + function close() { + if (step.type !== "process") { + onClose(); + if (step.type === "success" && onSuccessAndClose) { + onSuccessAndClose?.(step.data); + } + } else { + console.log("Attempt to close modal dialog when processing."); + } + } + + function onConfirm() { + const result = confirm(); + if (result.type === "ok") { + setStep({ type: "process" }); + setAllDisabled(true); + onProcess(result.values).then( + (d) => { + setStep({ + type: "success", + data: d, + }); + }, + (e: unknown) => { + setStep({ + type: "failure", + data: e, + }); + }, + ); + } + } + + let body: ReactNode; + let buttons: ComponentProps["buttons"]; + + if (step.type === "input" || step.type === "process") { + const isProcessing = step.type === "process"; + + body = ( +
+ + +
+ ); + buttons = [ + { + key: "cancel", + type: "normal", + props: { + text: "operationDialog.cancel", + color: "secondary", + outline: true, + onClick: close, + disabled: isProcessing, + }, + }, + { + key: "confirm", + type: "loading", + props: { + text: "operationDialog.confirm", + color, + loading: isProcessing, + disabled: hasErrorAndDirty, + onClick: onConfirm, + }, + }, + ]; + } else { + const result = step; + + const promptProps: OperationDialogPromptProps = + result.type === "success" + ? { + message: "operationDialog.success", + customMessage: successPrompt?.(result.data), + customMessageNode: successPromptNode?.(result.data), + } + : { + message: "operationDialog.error", + customMessage: failurePrompt?.(result.data), + customMessageNode: failurePromptNode?.(result.data), + }; + body = ( +
+ +
+ ); + + buttons = [ + { + key: "ok", + type: "normal", + props: { + text: "operationDialog.ok", + color: "primary", + onClick: close, + }, + }, + ]; + } + + return ( + + + {body} + + + ); +} + +export default OperationDialog; diff --git a/FrontEnd/src/components/dialog/index.ts b/FrontEnd/src/components/dialog/index.ts new file mode 100644 index 00000000..59f15791 --- /dev/null +++ b/FrontEnd/src/components/dialog/index.ts @@ -0,0 +1,64 @@ +import { useState } from "react"; + +export { default as Dialog } from "./Dialog"; +export { default as FullPageDialog } from "./FullPageDialog"; +export { default as OperationDialog } from "./OperationDialog"; +export { default as ConfirmDialog } from "./ConfirmDialog"; + +type DialogMap = { + [K in D]: V; +}; + +type DialogKeyMap = DialogMap; + +type DialogPropsMap = DialogMap< + D, + { key: number | string; open: boolean; onClose: () => void } +>; + +export function useDialog( + dialogs: D[], + options?: { + initDialog?: D | null; + onClose?: { + [K in D]?: () => void; + }; + }, +): { + dialog: D | null; + switchDialog: (newDialog: D | null) => void; + dialogPropsMap: DialogPropsMap; + createDialogSwitch: (newDialog: D | null) => () => void; +} { + const [dialog, setDialog] = useState(options?.initDialog ?? null); + + const [dialogKeys, setDialogKeys] = useState>( + () => Object.fromEntries(dialogs.map((d) => [d, 0])) as DialogKeyMap, + ); + + const switchDialog = (newDialog: D | null) => { + if (dialog !== null) { + setDialogKeys({ ...dialogKeys, [dialog]: dialogKeys[dialog] + 1 }); + } + setDialog(newDialog); + }; + + return { + dialog, + switchDialog, + dialogPropsMap: Object.fromEntries( + dialogs.map((d) => [ + d, + { + key: `${d}-${dialogKeys[d]}`, + open: dialog === d, + onClose: () => { + switchDialog(null); + options?.onClose?.[d]?.(); + }, + }, + ]), + ) as DialogPropsMap, + createDialogSwitch: (newDialog: D | null) => () => switchDialog(newDialog), + }; +} diff --git a/FrontEnd/src/components/hooks.ts b/FrontEnd/src/components/hooks.ts new file mode 100644 index 00000000..523a4538 --- /dev/null +++ b/FrontEnd/src/components/hooks.ts @@ -0,0 +1,14 @@ +// TODO: Migrate hooks + +export { + useIsSmallScreen, + useClickOutside, + useScrollToBottom, +} from "~src/utilities/hooks"; + +import { useMediaQuery } from "react-responsive"; +import { breakpoints } from "./breakpoints"; + +export function useMobile(): boolean { + return useMediaQuery({ maxWidth: breakpoints.sm }); +} diff --git a/FrontEnd/src/components/index.css b/FrontEnd/src/components/index.css new file mode 100644 index 00000000..a8f5e9a5 --- /dev/null +++ b/FrontEnd/src/components/index.css @@ -0,0 +1,100 @@ +@import "./theme.css"; + +* { + box-sizing: border-box; + margin-inline: 0; + margin-block: 0; +} + +body { + font-family: var(--cru-default-font-family); + background: var(--cru-body-background-color); + color: var(--cru-text-primary-color); + line-height: 1.2; +} + +.cru-page { + padding: var(--cru-page-padding); +} + +.cru-page-no-top-padding { + padding-top: 0; +} + +.cru-text-center { + text-align: center; +} + +.cru-text-end { + text-align: end; +} + +.cru-float-left { + float: left; +} + +.cru-float-right { + float: right; +} + +.cru-align-text-bottom { + vertical-align: text-bottom; +} + +.cru-align-middle { + vertical-align: middle; +} + +.cru-clearfix::after { + clear: both; +} + +.cru-fill-parent { + width: 100%; + height: 100%; +} + +.cru-avatar { + width: 60px; + height: 60px; +} + +.cru-avatar.large { + width: 100px; + height: 100px; +} + +.cru-avatar.small { + width: 40px; + height: 40px; +} + +.cru-round { + border-radius: 50%; +} + +.cru-tab-pages-action-area { + display: flex; + align-items: center; +} + +.alert-container { + position: fixed; + z-index: 1070; +} + +@media (min-width: 576px) { + .alert-container { + bottom: 0; + right: 0; + } +} + +@media (max-width: 575.98px) { + .alert-container { + bottom: 0; + right: 0; + left: 0; + text-align: center; + } +} \ No newline at end of file diff --git a/FrontEnd/src/components/input/InputGroup.css b/FrontEnd/src/components/input/InputGroup.css new file mode 100644 index 00000000..7e905b1e --- /dev/null +++ b/FrontEnd/src/components/input/InputGroup.css @@ -0,0 +1,54 @@ +.cru-input-group { + display: block; +} + +.cru-input-container { + margin: 0.4em 0; +} + +.cru-input-label { + display: block; + color: var(--cru-clickable-normal-color); + font-size: 0.9em; + margin-bottom: 0.3em; +} + +.cru-input-label-inline { + margin-inline-start: 0.5em; +} + +.cru-input-type-text input { + appearance: none; + display: block; + border: 1px solid; + /* color: var(--cru-surface-on-color); */ + /* background-color: var(--cru-surface-color); */ + margin: 0; + font-size: 1em; + padding: 0.2em; +} + +.cru-input-type-text input:hover { + border-color: var(--cru-clickable-hover-color); +} + +.cru-input-type-text input:focus { + border-color: var(--cru-clickable-focus-color); +} + +.cru-input-type-text input:disabled { + border-color: var(--cru-clickable-disabled-color); +} + +.cru-input-error { + display: block; + font-size: 0.8em; + color: var(--cru-danger-color); + margin-top: 0.4em; +} + +.cru-input-helper { + display: block; + font-size: 0.8em; + color: var(--cru-primary-color); +} \ No newline at end of file diff --git a/FrontEnd/src/components/input/InputGroup.tsx b/FrontEnd/src/components/input/InputGroup.tsx new file mode 100644 index 00000000..4f487344 --- /dev/null +++ b/FrontEnd/src/components/input/InputGroup.tsx @@ -0,0 +1,463 @@ +/** + * Some notes for InputGroup: + * This is one of the most complicated components in this project. + * Probably because the feature is complex and involved user inputs. + * + * I hope it contains following features: + * - Input features + * - Supports a wide range of input types. + * - Validator to validate user inputs. + * - Can set initial values. + * - Dirty, aka, has user touched this input. + * - Developer friendly + * - Easy to use APIs. + * - Type check as much as possible. + * - UI + * - Configurable appearance. + * - Can display helper and error messages. + * - Easy to extend, like new input types. + * + * So here is some design decisions: + * Inputs are identified by its _key_. + * `InputGroup` component takes care of only UI and no logic. + * `useInputs` hook takes care of logic and generate props for `InputGroup`. + */ + +import { useState, Ref, useId } from "react"; +import classNames from "classnames"; + +import { useC, Text, ThemeColor } from "../common"; + +import "./InputGroup.css"; + +export interface InputBase { + key: string; + label: Text; + helper?: Text; + disabled?: boolean; + error?: Text; +} + +export interface TextInput extends InputBase { + type: "text"; + value: string; + password?: boolean; +} + +export interface BoolInput extends InputBase { + type: "bool"; + value: boolean; +} + +export interface SelectInputOption { + value: string; + label: Text; + icon?: string; +} + +export interface SelectInput extends InputBase { + type: "select"; + value: string; + options: SelectInputOption[]; +} + +export type Input = TextInput | BoolInput | SelectInput; + +export type InputValue = Input["value"]; + +export type InputValueDict = Record; +export type InputErrorDict = Record; +export type InputDisabledDict = Record; +export type InputDirtyDict = Record; +// use never so you don't have to cast everywhere +export type InputConfirmValueDict = Record; + +export type GeneralInputErrorDict = + | { + [key: string]: Text | null | undefined; + } + | null + | undefined; + +type MakeInputInfo = Omit; + +export type InputInfo = { + [I in Input as I["type"]]: MakeInputInfo; +}[Input["type"]]; + +export type Validator = ( + values: InputValueDict, + inputs: InputInfo[], +) => GeneralInputErrorDict; + +export type InputScheme = { + inputs: InputInfo[]; + validator?: Validator; +}; + +export type InputData = { + values: InputValueDict; + errors: InputErrorDict; + disabled: InputDisabledDict; + dirties: InputDirtyDict; +}; + +export type State = { + scheme: InputScheme; + data: InputData; +}; + +export type DataInitialization = { + values?: InputValueDict; + errors?: GeneralInputErrorDict; + disabled?: InputDisabledDict; + dirties?: InputDirtyDict; +}; + +export type Initialization = { + scheme: InputScheme; + dataInit?: DataInitialization; +}; + +export type GeneralInitialization = Initialization | InputScheme | InputInfo[]; + +export type Initializer = GeneralInitialization | (() => GeneralInitialization); + +export interface InputGroupProps { + color?: ThemeColor; + containerClassName?: string; + containerRef?: Ref; + + inputs: Input[]; + onChange: (index: number, value: Input["value"]) => void; +} + +function cleanObject(o: Record): Record> { + const result = { ...o }; + for (const key of Object.keys(result)) { + if (result[key] == null) { + delete result[key]; + } + } + return result as never; +} + +export type ConfirmResult = + | { + type: "ok"; + values: InputConfirmValueDict; + } + | { + type: "error"; + errors: InputErrorDict; + }; + +function validate( + validator: Validator | null | undefined, + values: InputValueDict, + inputs: InputInfo[], +): InputErrorDict { + return cleanObject(validator?.(values, inputs) ?? {}); +} + +export function useInputs(options: { init: Initializer }): { + inputGroupProps: InputGroupProps; + hasError: boolean; + hasErrorAndDirty: boolean; + confirm: () => ConfirmResult; + setAllDisabled: (disabled: boolean) => void; +} { + function initializeValue( + input: InputInfo, + value?: InputValue | null, + ): InputValue { + if (input.type === "text") { + return value ?? ""; + } else if (input.type === "bool") { + return value ?? false; + } else if (input.type === "select") { + return value ?? input.options[0].value; + } + throw new Error("Unknown input type"); + } + + function initialize(generalInitialization: GeneralInitialization): State { + const initialization: Initialization = Array.isArray(generalInitialization) + ? { scheme: { inputs: generalInitialization } } + : "scheme" in generalInitialization + ? generalInitialization + : { scheme: generalInitialization }; + + const { scheme, dataInit } = initialization; + const { inputs, validator } = scheme; + const keys = inputs.map((input) => input.key); + + if (process.env.NODE_ENV === "development") { + const checkKeys = (dict: Record | 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( + dict: Record | null | undefined, + ): Record> { + 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(() => initialize(initializer())); + + const { scheme, data } = state; + const { validator } = scheme; + + function createAllBooleanDict(value: boolean): Record { + 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 ( +
+ {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 ( +
+ {label && ( + + )} + { + const v = event.target.value; + changeValue(v); + }} + disabled={disabled} + /> + {error &&
{c(error)}
} + {helper &&
{c(helper)}
} +
+ ); + } else if (type === "bool") { + return ( +
+ { + const v = event.currentTarget.checked; + changeValue(v); + }} + disabled={disabled} + /> + + {error &&
{c(error)}
} + {helper &&
{c(helper)}
} +
+ ); + } else if (type === "select") { + return ( +
+ + +
+ ); + } + })} +
+ ); +} diff --git a/FrontEnd/src/components/input/index.ts b/FrontEnd/src/components/input/index.ts new file mode 100644 index 00000000..ca183089 --- /dev/null +++ b/FrontEnd/src/components/input/index.ts @@ -0,0 +1,11 @@ +export { useInputs, InputGroup } from "./InputGroup"; + +export type { + InputValueDict, + InputErrorDict, + InputDirtyDict, + InputDisabledDict, + InputConfirmValueDict, + Validator, + Initializer, +} from "./InputGroup"; diff --git a/FrontEnd/src/components/list/ListContainer.css b/FrontEnd/src/components/list/ListContainer.css new file mode 100644 index 00000000..53781834 --- /dev/null +++ b/FrontEnd/src/components/list/ListContainer.css @@ -0,0 +1,4 @@ +.cru-list-container { + border: 1px solid var(--cru-clickable-primary-normal-color); + border-radius: 5px; +} diff --git a/FrontEnd/src/components/list/ListContainer.tsx b/FrontEnd/src/components/list/ListContainer.tsx new file mode 100644 index 00000000..aa00d12c --- /dev/null +++ b/FrontEnd/src/components/list/ListContainer.tsx @@ -0,0 +1,23 @@ +import { ComponentPropsWithoutRef, forwardRef, Ref } from "react"; +import classNames from "classnames"; + +import "./ListContainer.css" + +function _ListContainer( + { className, children, ...otherProps }: ComponentPropsWithoutRef<"div">, + ref: Ref, +) { + return ( +
+ {children} +
+ ); +} + +const ListContainer = forwardRef(_ListContainer); + +export default ListContainer; diff --git a/FrontEnd/src/components/list/ListItemContainer.css b/FrontEnd/src/components/list/ListItemContainer.css new file mode 100644 index 00000000..8d7afa9f --- /dev/null +++ b/FrontEnd/src/components/list/ListItemContainer.css @@ -0,0 +1,3 @@ +.cru-list-item-container { + border: 1px solid var(--cru-clickable-primary-normal-color); +} diff --git a/FrontEnd/src/components/list/ListItemContainer.tsx b/FrontEnd/src/components/list/ListItemContainer.tsx new file mode 100644 index 00000000..315cbd6e --- /dev/null +++ b/FrontEnd/src/components/list/ListItemContainer.tsx @@ -0,0 +1,23 @@ +import { ComponentPropsWithoutRef, forwardRef, Ref } from "react"; +import classNames from "classnames"; + +import "./ListItemContainer.css"; + +function _ListItemContainer( + { className, children, ...otherProps }: ComponentPropsWithoutRef<"div">, + ref: Ref, +) { + return ( +
+ {children} +
+ ); +} + +const ListItemContainer = forwardRef(_ListItemContainer); + +export default ListItemContainer; diff --git a/FrontEnd/src/components/list/index.ts b/FrontEnd/src/components/list/index.ts new file mode 100644 index 00000000..e183f7da --- /dev/null +++ b/FrontEnd/src/components/list/index.ts @@ -0,0 +1,4 @@ +import ListContainer from "./ListContainer"; +import ListItemContainer from "./ListItemContainer"; + +export { ListContainer, ListItemContainer }; diff --git a/FrontEnd/src/components/menu/Menu.css b/FrontEnd/src/components/menu/Menu.css new file mode 100644 index 00000000..75734533 --- /dev/null +++ b/FrontEnd/src/components/menu/Menu.css @@ -0,0 +1,36 @@ +.cru-menu { + min-width: 200px; +} + +.cru-menu-item { + display: block; + font-size: 1em; + width: 100%; + padding: 0.5em 1.5em; + transition: all 0.5s; + color: var(--cru-clickable-normal-color); + background-color: var(--cru-clickable-grayscale-normal-color); + border: none; + cursor: pointer; +} + +.cru-menu-item:hover { + background-color: var(--cru-clickable-grayscale-hover-color); +} + +.cru-menu-item:focus { + background-color: var(--cru-clickable-grayscale-focus-color); +} + +.cru-menu-item:active { + background-color: var(--cru-clickable-grayscale-active-color); +} + +.cru-menu-item-icon { + margin-right: 1em; +} + +.cru-menu-divider { + border-width: 0; + border-top: 1px solid var(--cru-primary-color); +} \ No newline at end of file diff --git a/FrontEnd/src/components/menu/Menu.tsx b/FrontEnd/src/components/menu/Menu.tsx new file mode 100644 index 00000000..e8099c76 --- /dev/null +++ b/FrontEnd/src/components/menu/Menu.tsx @@ -0,0 +1,62 @@ +import { CSSProperties } from "react"; +import classNames from "classnames"; + +import { useC, Text, ThemeColor } from "../common"; + +import "./Menu.css"; +import Icon from "../Icon"; + +export type MenuItem = + | { + type: "divider"; + } + | { + type: "button"; + text: Text; + icon?: string; + color?: ThemeColor; + onClick: () => void; + }; + +export type MenuItems = MenuItem[]; + +export type MenuProps = { + items: MenuItems; + onItemClicked?: () => void; + className?: string; + style?: CSSProperties; +}; + +export default function Menu({ + items, + onItemClicked, + className, + style, +}: MenuProps) { + const c = useC(); + + return ( +
+ {items.map((item, index) => { + if (item.type === "divider") { + return
; + } else { + const { text, color, icon, onClick } = item; + return ( + + ); + } + })} +
+ ); +} diff --git a/FrontEnd/src/components/menu/PopupMenu.css b/FrontEnd/src/components/menu/PopupMenu.css new file mode 100644 index 00000000..149e0699 --- /dev/null +++ b/FrontEnd/src/components/menu/PopupMenu.css @@ -0,0 +1,7 @@ +.cru-popup-menu-menu-container { + z-index: 1040; + border-radius: 3px; + border: var(--cru-clickable-normal-color) 1.5px solid; + background-color: var(--cru-background-color); + overflow: hidden; +} diff --git a/FrontEnd/src/components/menu/PopupMenu.tsx b/FrontEnd/src/components/menu/PopupMenu.tsx new file mode 100644 index 00000000..23a67f79 --- /dev/null +++ b/FrontEnd/src/components/menu/PopupMenu.tsx @@ -0,0 +1,73 @@ +import { useState, CSSProperties, ReactNode } from "react"; +import classNames from "classnames"; +import { createPortal } from "react-dom"; +import { usePopper } from "react-popper"; + +import { useClickOutside } from "~src/utilities/hooks"; + +import Menu, { MenuItems } from "./Menu"; + +import { ThemeColor } from "../common"; + +import "./PopupMenu.css"; + +export interface PopupMenuProps { + color?: ThemeColor; + items: MenuItems; + children?: ReactNode; + containerClassName?: string; + containerStyle?: CSSProperties; +} + +export default function PopupMenu({ + color, + items, + children, + containerClassName, + containerStyle, +}: PopupMenuProps) { + const [show, setShow] = useState(false); + + const [referenceElement, setReferenceElement] = + useState(null); + const [popperElement, setPopperElement] = useState( + null, + ); + const { styles, attributes } = usePopper(referenceElement, popperElement); + + useClickOutside(popperElement, () => setShow(false), true); + + return ( +
setShow(true)} + > + {children} + {show && + createPortal( +
+ { + setShow(false); + }} + /> +
, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + document.getElementById("portal")!, + )} +
+ ); +} diff --git a/FrontEnd/src/components/tab/TabPages.tsx b/FrontEnd/src/components/tab/TabPages.tsx new file mode 100644 index 00000000..6a5f4469 --- /dev/null +++ b/FrontEnd/src/components/tab/TabPages.tsx @@ -0,0 +1,71 @@ +import * as React from "react"; + +import { I18nText, UiLogicError } from "~src/common"; + +import Tabs from "./Tabs"; + +export interface TabPage { + name: string; + text: I18nText; + page: React.ReactNode; +} + +export interface TabPagesProps { + pages: TabPage[]; + actions?: React.ReactNode; + dense?: boolean; + className?: string; + style?: React.CSSProperties; + navClassName?: string; + navStyle?: React.CSSProperties; + pageContainerClassName?: string; + pageContainerStyle?: React.CSSProperties; +} + +const TabPages: React.FC = ({ + pages, + actions, + dense, + className, + style, + navClassName, + navStyle, + pageContainerClassName, + pageContainerStyle, +}) => { + if (pages.length === 0) { + throw new UiLogicError("Page list can't be empty."); + } + + const [tab, setTab] = React.useState(pages[0].name); + + const currentPage = pages.find((p) => p.name === tab); + + if (currentPage == null) { + throw new UiLogicError("Current tab value is bad."); + } + + return ( +
+ ({ + name: page.name, + text: page.text, + onClick: () => { + setTab(page.name); + }, + }))} + dense={dense} + activeTabName={tab} + className={navClassName} + style={navStyle} + actions={actions} + /> +
+ {currentPage.page} +
+
+ ); +}; + +export default TabPages; diff --git a/FrontEnd/src/components/tab/Tabs.css b/FrontEnd/src/components/tab/Tabs.css new file mode 100644 index 00000000..395d16a7 --- /dev/null +++ b/FrontEnd/src/components/tab/Tabs.css @@ -0,0 +1,33 @@ +.cru-nav { + border-bottom: var(--cru-primary-color) 1px solid; + display: flex; +} + +.cru-nav-item { + color: var(--cru-primary-color); + border: var(--cru-background-2-color) 0.5px solid; + border-bottom: none; + padding: 0.5em 1.5em; + border-top-left-radius: 5px; + border-top-right-radius: 5px; + transition: all 0.5s; + cursor: pointer; +} + +.cru-nav.dense .cru-nav-item { + padding: 0.2em 1em; +} + +.cru-nav-item:hover { + background-color: var(--cru-background-1-color); +} + +.cru-nav-item.active { + color: var(--cru-primary-t-color); + background-color: var(--cru-primary-color); + border-color: var(--cru-primary-color); +} + +.cru-nav-action-area { + margin-left: auto; +} diff --git a/FrontEnd/src/components/tab/Tabs.tsx b/FrontEnd/src/components/tab/Tabs.tsx new file mode 100644 index 00000000..dc8d9c01 --- /dev/null +++ b/FrontEnd/src/components/tab/Tabs.tsx @@ -0,0 +1,62 @@ +import * as React from "react"; +import { Link } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import classnames from "classnames"; + +import { convertI18nText, I18nText } from "~src/common"; + +import "./Tabs.css"; + +export interface Tab { + name: string; + text: I18nText; + link?: string; + onClick?: () => void; +} + +export interface TabsProps { + activeTabName?: string; + actions?: React.ReactNode; + dense?: boolean; + tabs: Tab[]; + className?: string; + style?: React.CSSProperties; +} + +export default function Tabs(props: TabsProps): React.ReactElement | null { + const { tabs, activeTabName, className, style, dense, actions } = props; + + const { t } = useTranslation(); + + return ( +
+ {tabs.map((tab) => { + const active = activeTabName === tab.name; + const className = classnames("cru-nav-item", active && "active"); + + if (tab.link != null) { + return ( + + {convertI18nText(tab.text, t)} + + ); + } else { + return ( + + {convertI18nText(tab.text, t)} + + ); + } + })} +
{actions}
+
+ ); +} diff --git a/FrontEnd/src/components/theme-color.css b/FrontEnd/src/components/theme-color.css new file mode 100644 index 00000000..24a7e267 --- /dev/null +++ b/FrontEnd/src/components/theme-color.css @@ -0,0 +1,173 @@ +/* Generated by theme-generator.ts */ + +:root { + --cru-primary-color: hsl(210 100% 40%); + --cru-primary-1-color: hsl(210 100% 37%); + --cru-primary-2-color: hsl(210 100% 34%); + --cru-primary-on-color: hsl(210 100% 100%); + --cru-primary-container-color: hsl(210 100% 90%); + --cru-primary-container-1-color: hsl(210 100% 80%); + --cru-primary-container-2-color: hsl(210 100% 70%); + --cru-primary-on-container-color: hsl(210 100% 10%); + --cru-secondary-color: hsl(40 100% 40%); + --cru-secondary-1-color: hsl(40 100% 37%); + --cru-secondary-2-color: hsl(40 100% 34%); + --cru-secondary-on-color: hsl(40 100% 100%); + --cru-secondary-container-color: hsl(40 100% 90%); + --cru-secondary-container-1-color: hsl(40 100% 80%); + --cru-secondary-container-2-color: hsl(40 100% 70%); + --cru-secondary-on-container-color: hsl(40 100% 10%); + --cru-tertiary-color: hsl(160 100% 40%); + --cru-tertiary-1-color: hsl(160 100% 37%); + --cru-tertiary-2-color: hsl(160 100% 34%); + --cru-tertiary-on-color: hsl(160 100% 100%); + --cru-tertiary-container-color: hsl(160 100% 90%); + --cru-tertiary-container-1-color: hsl(160 100% 80%); + --cru-tertiary-container-2-color: hsl(160 100% 70%); + --cru-tertiary-on-container-color: hsl(160 100% 10%); + --cru-danger-color: hsl(0 100% 40%); + --cru-danger-1-color: hsl(0 100% 37%); + --cru-danger-2-color: hsl(0 100% 34%); + --cru-danger-on-color: hsl(0 100% 100%); + --cru-danger-container-color: hsl(0 100% 90%); + --cru-danger-container-1-color: hsl(0 100% 80%); + --cru-danger-container-2-color: hsl(0 100% 70%); + --cru-danger-on-container-color: hsl(0 100% 10%); + --cru-success-color: hsl(120 60% 40%); + --cru-success-1-color: hsl(120 60% 37%); + --cru-success-2-color: hsl(120 60% 34%); + --cru-success-on-color: hsl(120 60% 100%); + --cru-success-container-color: hsl(120 60% 90%); + --cru-success-container-1-color: hsl(120 60% 80%); + --cru-success-container-2-color: hsl(120 60% 70%); + --cru-success-on-container-color: hsl(120 60% 10%); + --cru-surface-dim-color: hsl(0 0% 87%); + --cru-surface-color: hsl(0 0% 98%); + --cru-surface-1-color: hsl(0 0% 90%); + --cru-surface-2-color: hsl(0 0% 82%); + --cru-surface-bright-color: hsl(0 0% 98%); + --cru-surface-container-lowest-color: hsl(0 0% 100%); + --cru-surface-container-low-color: hsl(0 0% 96%); + --cru-surface-container-color: hsl(0 0% 94%); + --cru-surface-container-high-color: hsl(0 0% 92%); + --cru-surface-container-highest-color: hsl(0 0% 90%); + --cru-surface-on-color: hsl(0 0% 10%); + --cru-surface-on-variant-color: hsl(0 0% 30%); + --cru-surface-outline-color: hsl(0 0% 50%); + --cru-surface-outline-variant-color: hsl(0 0% 80%); +} + +@media (prefers-color-scheme: dark) { + :root { + --cru-primary-color: hsl(210 100% 80%); + --cru-primary-1-color: hsl(210 100% 75%); + --cru-primary-2-color: hsl(210 100% 68%); + --cru-primary-on-color: hsl(210 100% 20%); + --cru-primary-container-color: hsl(210 100% 30%); + --cru-primary-container-1-color: hsl(210 100% 25%); + --cru-primary-container-2-color: hsl(210 100% 20%); + --cru-primary-on-container-color: hsl(210 100% 90%); + --cru-secondary-color: hsl(40 100% 80%); + --cru-secondary-1-color: hsl(40 100% 75%); + --cru-secondary-2-color: hsl(40 100% 68%); + --cru-secondary-on-color: hsl(40 100% 20%); + --cru-secondary-container-color: hsl(40 100% 30%); + --cru-secondary-container-1-color: hsl(40 100% 25%); + --cru-secondary-container-2-color: hsl(40 100% 20%); + --cru-secondary-on-container-color: hsl(40 100% 90%); + --cru-tertiary-color: hsl(160 100% 80%); + --cru-tertiary-1-color: hsl(160 100% 75%); + --cru-tertiary-2-color: hsl(160 100% 68%); + --cru-tertiary-on-color: hsl(160 100% 20%); + --cru-tertiary-container-color: hsl(160 100% 30%); + --cru-tertiary-container-1-color: hsl(160 100% 25%); + --cru-tertiary-container-2-color: hsl(160 100% 20%); + --cru-tertiary-on-container-color: hsl(160 100% 90%); + --cru-danger-color: hsl(0 100% 80%); + --cru-danger-1-color: hsl(0 100% 75%); + --cru-danger-2-color: hsl(0 100% 68%); + --cru-danger-on-color: hsl(0 100% 20%); + --cru-danger-container-color: hsl(0 100% 30%); + --cru-danger-container-1-color: hsl(0 100% 25%); + --cru-danger-container-2-color: hsl(0 100% 20%); + --cru-danger-on-container-color: hsl(0 100% 90%); + --cru-success-color: hsl(120 60% 80%); + --cru-success-1-color: hsl(120 60% 75%); + --cru-success-2-color: hsl(120 60% 68%); + --cru-success-on-color: hsl(120 60% 20%); + --cru-success-container-color: hsl(120 60% 30%); + --cru-success-container-1-color: hsl(120 60% 25%); + --cru-success-container-2-color: hsl(120 60% 20%); + --cru-success-on-container-color: hsl(120 60% 90%); + --cru-surface-dim-color: hsl(0 0% 6%); + --cru-surface-color: hsl(0 0% 6%); + --cru-surface-1-color: hsl(0 0% 25%); + --cru-surface-2-color: hsl(0 0% 40%); + --cru-surface-bright-color: hsl(0 0% 24%); + --cru-surface-container-lowest-color: hsl(0 0% 4%); + --cru-surface-container-low-color: hsl(0 0% 10%); + --cru-surface-container-color: hsl(0 0% 12%); + --cru-surface-container-high-color: hsl(0 0% 17%); + --cru-surface-container-highest-color: hsl(0 0% 22%); + --cru-surface-on-color: hsl(0 0% 90%); + --cru-surface-on-variant-color: hsl(0 0% 80%); + --cru-surface-outline-color: hsl(0 0% 60%); + --cru-surface-outline-variant-color: hsl(0 0% 30%); + } +} + +.cru-primary { + --cru-key-color: var(--cru-primary-color); + --cru-key-1-color: var(--cru-primary-1-color); + --cru-key-2-color: var(--cru-primary-2-color); + --cru-key-on-color: var(--cru-primary-on-color); + --cru-key-container-color: var(--cru-primary-container-color); + --cru-key-container-1-color: var(--cru-primary-container-1-color); + --cru-key-container-2-color: var(--cru-primary-container-2-color); + --cru-key-on-container-color: var(--cru-primary-on-container-color); +} + +.cru-secondary { + --cru-key-color: var(--cru-secondary-color); + --cru-key-1-color: var(--cru-secondary-1-color); + --cru-key-2-color: var(--cru-secondary-2-color); + --cru-key-on-color: var(--cru-secondary-on-color); + --cru-key-container-color: var(--cru-secondary-container-color); + --cru-key-container-1-color: var(--cru-secondary-container-1-color); + --cru-key-container-2-color: var(--cru-secondary-container-2-color); + --cru-key-on-container-color: var(--cru-secondary-on-container-color); +} + +.cru-tertiary { + --cru-key-color: var(--cru-tertiary-color); + --cru-key-1-color: var(--cru-tertiary-1-color); + --cru-key-2-color: var(--cru-tertiary-2-color); + --cru-key-on-color: var(--cru-tertiary-on-color); + --cru-key-container-color: var(--cru-tertiary-container-color); + --cru-key-container-1-color: var(--cru-tertiary-container-1-color); + --cru-key-container-2-color: var(--cru-tertiary-container-2-color); + --cru-key-on-container-color: var(--cru-tertiary-on-container-color); +} + +.cru-danger { + --cru-key-color: var(--cru-danger-color); + --cru-key-1-color: var(--cru-danger-1-color); + --cru-key-2-color: var(--cru-danger-2-color); + --cru-key-on-color: var(--cru-danger-on-color); + --cru-key-container-color: var(--cru-danger-container-color); + --cru-key-container-1-color: var(--cru-danger-container-1-color); + --cru-key-container-2-color: var(--cru-danger-container-2-color); + --cru-key-on-container-color: var(--cru-danger-on-container-color); +} + +.cru-success { + --cru-key-color: var(--cru-success-color); + --cru-key-1-color: var(--cru-success-1-color); + --cru-key-2-color: var(--cru-success-2-color); + --cru-key-on-color: var(--cru-success-on-color); + --cru-key-container-color: var(--cru-success-container-color); + --cru-key-container-1-color: var(--cru-success-container-1-color); + --cru-key-container-2-color: var(--cru-success-container-2-color); + --cru-key-on-container-color: var(--cru-success-on-container-color); +} + diff --git a/FrontEnd/src/components/theme.css b/FrontEnd/src/components/theme.css new file mode 100644 index 00000000..6ceb369f --- /dev/null +++ b/FrontEnd/src/components/theme.css @@ -0,0 +1,146 @@ +@import "./theme-color.css"; + +:root { + --cru-default-font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; + --cru-page-padding: 1em 2em; + + --cru-border-radius: 4px; + --cru-card-border-radius: 4px; +} + +/* theme colors */ +:root { + --cru-primary-color: hsl(210, 100%, 50%); + --cru-secondary-color: hsl(30, 100%, 50%); + --cru-create-color: hsl(120, 100%, 25%); + --cru-danger-color: hsl(0, 100%, 50%); +} + +/* common colors */ +:root { + --cru-background-color: hsl(0, 0%, 100%); + --cru-container-background-color: hsl(0, 0%, 97%); + --cru-text-primary-color: hsl(0, 0%, 0%); + --cru-text-secondary-color: hsl(0, 0%, 38%); +} + +@media (prefers-color-scheme: dark) { + :root { + --cru-background-color: hsl(0, 0%, 0%); + --cru-container-background-color: hsl(0, 0%, 2%); + --cru-text-primary-color: hsl(0, 0%, 100%); + --cru-text-secondary-color: hsl(0, 0%, 85%); + } +} + +:root { + --cru-body-background-color: var(--cru-background-color); +} + +/* clickable color */ +:root { + --cru-clickable-primary-normal-color: var(--cru-primary-color); + --cru-clickable-primary-hover-color: hsl(210, 100%, 60%); + --cru-clickable-primary-focus-color: hsl(210, 100%, 60%); + --cru-clickable-primary-active-color: hsl(210, 100%, 70%); + --cru-clickable-secondary-normal-color: var(--cru-secondary-color); + --cru-clickable-secondary-hover-color: hsl(30, 100%, 60%); + --cru-clickable-secondary-focus-color: hsl(30, 100%, 60%); + --cru-clickable-secondary-active-color: hsl(30, 100%, 70%); + --cru-clickable-create-normal-color: var(--cru-create-color); + --cru-clickable-create-hover-color: hsl(120, 100%, 35%); + --cru-clickable-create-focus-color: hsl(120, 100%, 35%); + --cru-clickable-create-active-color: hsl(120, 100%, 35%); + --cru-clickable-danger-normal-color: var(--cru-danger-color); + --cru-clickable-danger-hover-color: hsl(0, 100%, 60%); + --cru-clickable-danger-focus-color: hsl(0, 100%, 60%); + --cru-clickable-danger-active-color: hsl(0, 100%, 70%); + --cru-clickable-grayscale-normal-color: hsl(0, 0%, 100%); + --cru-clickable-grayscale-hover-color: hsl(0, 0%, 92%); + --cru-clickable-grayscale-focus-color: hsl(0, 0%, 92%); + --cru-clickable-grayscale-active-color: hsl(0, 0%, 88%); + --cru-clickable-disabled-color: hsl(0, 0%, 50%); +} + +@media (prefers-color-scheme: dark) { + :root { + --cru-clickable-grayscale-normal-color: hsl(0, 0%, 0%); + --cru-clickable-grayscale-hover-color: hsl(0, 0%, 10%); + --cru-clickable-grayscale-focus-color: hsl(0, 0%, 10%); + --cru-clickable-grayscale-active-color: hsl(0, 0%, 20%); + } +} + +.cru-clickable-primary { + --cru-clickable-normal-color: var(--cru-clickable-primary-normal-color); + --cru-clickable-hover-color: var(--cru-clickable-primary-hover-color); + --cru-clickable-focus-color: var(--cru-clickable-primary-focus-color); + --cru-clickable-active-color: var(--cru-clickable-primary-active-color); +} + +.cru-clickable-secondary { + --cru-clickable-normal-color: var(--cru-clickable-secondary-normal-color); + --cru-clickable-hover-color: var(--cru-clickable-secondary-hover-color); + --cru-clickable-focus-color: var(--cru-clickable-secondary-focus-color); + --cru-clickable-active-color: var(--cru-clickable-secondary-active-color); +} + +.cru-clickable-create { + --cru-clickable-normal-color: var(--cru-clickable-create-normal-color); + --cru-clickable-hover-color: var(--cru-clickable-create-hover-color); + --cru-clickable-focus-color: var(--cru-clickable-create-focus-color); + --cru-clickable-active-color: var(--cru-clickable-create-active-color); +} + +.cru-clickable-danger { + --cru-clickable-normal-color: var(--cru-clickable-danger-normal-color); + --cru-clickable-hover-color: var(--cru-clickable-danger-hover-color); + --cru-clickable-focus-color: var(--cru-clickable-danger-focus-color); + --cru-clickable-active-color: var(--cru-clickable-danger-active-color); +} + +.cru-clickable-grayscale { + --cru-clickable-normal-color: var(--cru-clickable-grayscale-normal-color); + --cru-clickable-hover-color: var(--cru-clickable-grayscale-hover-color); + --cru-clickable-focus-color: var(--cru-clickable-grayscale-focus-color); + --cru-clickable-active-color: var(--cru-clickable-grayscale-active-color); +} + +/* button colors */ +:root { + /* push button colors */ + --cru-push-button-text-color: #ffffff; + --cru-push-button-disabled-text-color: hsl(0, 0%, 80%); +} + +/* Card colors */ +:root { + --cru-card-background-primary-color: hsl(210, 100%, 50%); + --cru-card-border-primary-color: hsl(210, 100%, 50%); + --cru-card-background-secondary-color: hsl(30, 100%, 50%); + --cru-card-border-secondary-color: hsl(30, 100%, 50%); + --cru-card-background-create-color: hsl(120, 100%, 25%); + --cru-card-border-create-color: hsl(120, 100%, 25%); + --cru-card-background-danger-color: hsl(0, 100%, 50%); + --cru-card-border-danger-color: hsl(0, 100%, 50%); +} + +.cru-card-primary { + --cru-card-background-color: var(--cru-card-background-primary-color); + --cru-card-border-color: var(--cru-card-border-primary-color) +} + +.cru-card-secondary { + --cru-card-background-color: var(--cru-card-background-secondary-color); + --cru-card-border-color: var(--cru-card-border-secondary-color) +} + +.cru-card-create { + --cru-card-background-color: var(--cru-card-background-create-color); + --cru-card-border-color: var(--cru-card-border-create-color) +} + +.cru-card-danger { + --cru-card-background-color: var(--cru-card-background-danger-color); + --cru-card-border-color: var(--cru-card-border-danger-color) +} \ No newline at end of file diff --git a/FrontEnd/src/components/user/UserAvatar.tsx b/FrontEnd/src/components/user/UserAvatar.tsx new file mode 100644 index 00000000..8671f2d8 --- /dev/null +++ b/FrontEnd/src/components/user/UserAvatar.tsx @@ -0,0 +1,22 @@ +import { Ref, ComponentPropsWithoutRef } from "react"; + +import { getHttpUserClient } from "~src/http/user"; + +export interface UserAvatarProps extends ComponentPropsWithoutRef<"img"> { + username: string; + imgRef?: Ref | null; +} + +export default function UserAvatar({ + username, + imgRef, + ...otherProps +}: UserAvatarProps) { + return ( + + ); +} -- cgit v1.2.3 From 256cc9592a3f31fc392e1ccdb699aa206b7b47ce Mon Sep 17 00:00:00 2001 From: crupest Date: Sat, 26 Aug 2023 23:49:28 +0800 Subject: ... --- FrontEnd/src/common.ts | 2 - FrontEnd/src/components/AppBar.tsx | 3 +- FrontEnd/src/components/Card.tsx | 1 + FrontEnd/src/components/LoadFailReload.tsx | 37 ------------------ FrontEnd/src/components/LoadingPage.tsx | 13 ------- FrontEnd/src/components/SearchInput.tsx | 2 +- FrontEnd/src/components/button/ButtonRowV2.tsx | 3 +- FrontEnd/src/components/button/LoadingButton.tsx | 1 - FrontEnd/src/components/common.ts | 1 - FrontEnd/src/components/dialog/ConfirmDialog.css | 0 FrontEnd/src/components/dialog/ConfirmDialog.tsx | 1 - FrontEnd/src/components/dialog/OperationDialog.tsx | 7 +--- FrontEnd/src/components/dialog/index.ts | 1 + FrontEnd/src/components/hooks.ts | 14 ------- FrontEnd/src/components/hooks/index.ts | 3 ++ FrontEnd/src/components/hooks/responsive.ts | 7 ++++ FrontEnd/src/components/hooks/useClickOutside.ts | 38 ++++++++++++++++++ FrontEnd/src/components/hooks/useScrollToBottom.ts | 44 +++++++++++++++++++++ FrontEnd/src/components/input/InputGroup.tsx | 16 ++++---- FrontEnd/src/components/menu/Menu.tsx | 2 +- FrontEnd/src/components/menu/PopupMenu.tsx | 6 +-- FrontEnd/src/pages/login/index.tsx | 14 ++----- FrontEnd/src/pages/register/index.tsx | 26 +++++-------- FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx | 5 +-- .../src/pages/setting/ChangePasswordDialog.tsx | 17 ++++---- FrontEnd/src/pages/setting/index.tsx | 11 +++--- FrontEnd/src/pages/timeline/Timeline.tsx | 3 +- .../src/pages/timeline/TimelineDeleteDialog.tsx | 4 +- FrontEnd/src/pages/timeline/TimelinePostView.tsx | 8 ++-- FrontEnd/src/utilities/hooks.ts | 5 --- FrontEnd/src/utilities/hooks/mediaQuery.ts | 5 --- FrontEnd/src/utilities/hooks/useClickOutside.ts | 38 ------------------ FrontEnd/src/utilities/hooks/useScrollToBottom.ts | 45 ---------------------- 33 files changed, 149 insertions(+), 234 deletions(-) delete mode 100644 FrontEnd/src/components/LoadFailReload.tsx delete mode 100644 FrontEnd/src/components/LoadingPage.tsx delete mode 100644 FrontEnd/src/components/dialog/ConfirmDialog.css delete mode 100644 FrontEnd/src/components/hooks.ts create mode 100644 FrontEnd/src/components/hooks/index.ts create mode 100644 FrontEnd/src/components/hooks/responsive.ts create mode 100644 FrontEnd/src/components/hooks/useClickOutside.ts create mode 100644 FrontEnd/src/components/hooks/useScrollToBottom.ts delete mode 100644 FrontEnd/src/utilities/hooks.ts delete mode 100644 FrontEnd/src/utilities/hooks/mediaQuery.ts delete mode 100644 FrontEnd/src/utilities/hooks/useClickOutside.ts delete mode 100644 FrontEnd/src/utilities/hooks/useScrollToBottom.ts (limited to 'FrontEnd/src/components') diff --git a/FrontEnd/src/common.ts b/FrontEnd/src/common.ts index 7c053140..1ca796c3 100644 --- a/FrontEnd/src/common.ts +++ b/FrontEnd/src/common.ts @@ -3,8 +3,6 @@ // This error should never occur. If it does, it indicates there is some logic bug in codes. export class UiLogicError extends Error {} -export const highlightTimelineUsername = "crupest"; - export type { I18nText } from "./i18n"; export type { I18nText as Text } from "./i18n"; export { c, convertI18nText } from "./i18n"; diff --git a/FrontEnd/src/components/AppBar.tsx b/FrontEnd/src/components/AppBar.tsx index da3a946f..1a5c1941 100644 --- a/FrontEnd/src/components/AppBar.tsx +++ b/FrontEnd/src/components/AppBar.tsx @@ -2,9 +2,10 @@ import { useState } from "react"; import classnames from "classnames"; import { Link, NavLink } from "react-router-dom"; -import { I18nText, useC, useMobile } from "./common"; import { useUser } from "~src/services/user"; +import { I18nText, useC } from "./common"; +import { useMobile } from "./hooks"; import TimelineLogo from "./TimelineLogo"; import { IconButton } from "./button"; import UserAvatar from "./user/UserAvatar"; diff --git a/FrontEnd/src/components/Card.tsx b/FrontEnd/src/components/Card.tsx index a8f0d3cc..5d3ef630 100644 --- a/FrontEnd/src/components/Card.tsx +++ b/FrontEnd/src/components/Card.tsx @@ -2,6 +2,7 @@ import { ComponentPropsWithoutRef, Ref } from "react"; import classNames from "classnames"; import { ThemeColor } from "./common"; + import "./Card.css"; interface CardProps extends ComponentPropsWithoutRef<"div"> { diff --git a/FrontEnd/src/components/LoadFailReload.tsx b/FrontEnd/src/components/LoadFailReload.tsx deleted file mode 100644 index 81ba1f67..00000000 --- a/FrontEnd/src/components/LoadFailReload.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import * as React from "react"; -import { Trans } from "react-i18next"; - -export interface LoadFailReloadProps { - className?: string; - style?: React.CSSProperties; - onReload: () => void; -} - -const LoadFailReload: React.FC = ({ - onReload, - className, - style, -}) => { - return ( - - 0 - { - onReload(); - e.preventDefault(); - }} - > - 1 - - 2 - - ); -}; - -export default LoadFailReload; diff --git a/FrontEnd/src/components/LoadingPage.tsx b/FrontEnd/src/components/LoadingPage.tsx deleted file mode 100644 index 35ee1aa8..00000000 --- a/FrontEnd/src/components/LoadingPage.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import * as React from "react"; - -import Spinner from "./Spinner"; - -const LoadingPage: React.FC = () => { - return ( -
- -
- ); -}; - -export default LoadingPage; diff --git a/FrontEnd/src/components/SearchInput.tsx b/FrontEnd/src/components/SearchInput.tsx index e3216b86..71820bfa 100644 --- a/FrontEnd/src/components/SearchInput.tsx +++ b/FrontEnd/src/components/SearchInput.tsx @@ -1,7 +1,7 @@ import classNames from "classnames"; import { useC, Text } from "./common"; -import LoadingButton from "./button/LoadingButton"; +import { LoadingButton } from "./button"; import "./SearchInput.css"; diff --git a/FrontEnd/src/components/button/ButtonRowV2.tsx b/FrontEnd/src/components/button/ButtonRowV2.tsx index 3467ad52..5129e7f1 100644 --- a/FrontEnd/src/components/button/ButtonRowV2.tsx +++ b/FrontEnd/src/components/button/ButtonRowV2.tsx @@ -1,13 +1,14 @@ import { ComponentPropsWithoutRef, Ref } from "react"; import classNames from "classnames"; +import { Text, ThemeColor } from "../common"; + import Button from "./Button"; import FlatButton from "./FlatButton"; import IconButton from "./IconButton"; import LoadingButton from "./LoadingButton"; import "./ButtonRow.css"; -import { Text, ThemeColor } from "../common"; interface ButtonRowV2ButtonBase { key: string | number; diff --git a/FrontEnd/src/components/button/LoadingButton.tsx b/FrontEnd/src/components/button/LoadingButton.tsx index 7e7d08e6..d9d41ddb 100644 --- a/FrontEnd/src/components/button/LoadingButton.tsx +++ b/FrontEnd/src/components/button/LoadingButton.tsx @@ -1,7 +1,6 @@ import classNames from "classnames"; import { I18nText, ThemeColor, useC } from "../common"; - import Spinner from "../Spinner"; import "./LoadingButton.css"; diff --git a/FrontEnd/src/components/common.ts b/FrontEnd/src/components/common.ts index e6f7319f..b96388ab 100644 --- a/FrontEnd/src/components/common.ts +++ b/FrontEnd/src/components/common.ts @@ -11,4 +11,3 @@ export const themeColors = [ export type ThemeColor = (typeof themeColors)[number]; export { breakpoints } from "./breakpoints"; -export { useMobile } from "./hooks"; diff --git a/FrontEnd/src/components/dialog/ConfirmDialog.css b/FrontEnd/src/components/dialog/ConfirmDialog.css deleted file mode 100644 index e69de29b..00000000 diff --git a/FrontEnd/src/components/dialog/ConfirmDialog.tsx b/FrontEnd/src/components/dialog/ConfirmDialog.tsx index 26939c9b..1d997305 100644 --- a/FrontEnd/src/components/dialog/ConfirmDialog.tsx +++ b/FrontEnd/src/components/dialog/ConfirmDialog.tsx @@ -1,5 +1,4 @@ import { useC, Text, ThemeColor } from "../common"; - import Dialog from "./Dialog"; import DialogContainer from "./DialogContainer"; diff --git a/FrontEnd/src/components/dialog/OperationDialog.tsx b/FrontEnd/src/components/dialog/OperationDialog.tsx index e5db7f4f..96766825 100644 --- a/FrontEnd/src/components/dialog/OperationDialog.tsx +++ b/FrontEnd/src/components/dialog/OperationDialog.tsx @@ -2,23 +2,18 @@ import { useState, ReactNode, ComponentProps } from "react"; import classNames from "classnames"; import { useC, Text, ThemeColor } from "../common"; - import { useInputs, InputGroup, Initializer as InputInitializer, - InputValueDict, - InputErrorDict, InputConfirmValueDict, } from "../input"; +import { ButtonRow } from "../button"; import Dialog from "./Dialog"; import DialogContainer from "./DialogContainer"; -import { ButtonRow } from "../button"; import "./OperationDialog.css"; -export type { InputInitializer, InputValueDict, InputErrorDict }; - interface OperationDialogPromptProps { message?: Text; customMessage?: Text; diff --git a/FrontEnd/src/components/dialog/index.ts b/FrontEnd/src/components/dialog/index.ts index 59f15791..17db8fd0 100644 --- a/FrontEnd/src/components/dialog/index.ts +++ b/FrontEnd/src/components/dialog/index.ts @@ -4,6 +4,7 @@ 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"; type DialogMap = { [K in D]: V; diff --git a/FrontEnd/src/components/hooks.ts b/FrontEnd/src/components/hooks.ts deleted file mode 100644 index 523a4538..00000000 --- a/FrontEnd/src/components/hooks.ts +++ /dev/null @@ -1,14 +0,0 @@ -// TODO: Migrate hooks - -export { - useIsSmallScreen, - useClickOutside, - useScrollToBottom, -} from "~src/utilities/hooks"; - -import { useMediaQuery } from "react-responsive"; -import { breakpoints } from "./breakpoints"; - -export function useMobile(): boolean { - return useMediaQuery({ maxWidth: breakpoints.sm }); -} diff --git a/FrontEnd/src/components/hooks/index.ts b/FrontEnd/src/components/hooks/index.ts new file mode 100644 index 00000000..3c9859bc --- /dev/null +++ b/FrontEnd/src/components/hooks/index.ts @@ -0,0 +1,3 @@ +export { useMobile } from "./responsive"; +export { default as useClickOutside } from "./useClickOutside"; +export { default as useScrollToBottom } from "./useScrollToBottom"; diff --git a/FrontEnd/src/components/hooks/responsive.ts b/FrontEnd/src/components/hooks/responsive.ts new file mode 100644 index 00000000..6bcce96c --- /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(): boolean { + return useMediaQuery({ maxWidth: breakpoints.sm }); +} 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/input/InputGroup.tsx b/FrontEnd/src/components/input/InputGroup.tsx index 4f487344..47a43b38 100644 --- a/FrontEnd/src/components/input/InputGroup.tsx +++ b/FrontEnd/src/components/input/InputGroup.tsx @@ -72,12 +72,9 @@ export type InputDirtyDict = Record; // use never so you don't have to cast everywhere export type InputConfirmValueDict = Record; -export type GeneralInputErrorDict = - | { - [key: string]: Text | null | undefined; - } - | null - | undefined; +export type GeneralInputErrorDict = { + [key: string]: Text | null | undefined; +}; type MakeInputInfo = Omit; @@ -87,8 +84,9 @@ export type InputInfo = { export type Validator = ( values: InputValueDict, + errors: GeneralInputErrorDict, inputs: InputInfo[], -) => GeneralInputErrorDict; +) => void; export type InputScheme = { inputs: InputInfo[]; @@ -157,7 +155,9 @@ function validate( values: InputValueDict, inputs: InputInfo[], ): InputErrorDict { - return cleanObject(validator?.(values, inputs) ?? {}); + const errors: GeneralInputErrorDict = {}; + validator?.(values, errors, inputs); + return cleanObject(errors); } export function useInputs(options: { init: Initializer }): { diff --git a/FrontEnd/src/components/menu/Menu.tsx b/FrontEnd/src/components/menu/Menu.tsx index e8099c76..c01c6cfb 100644 --- a/FrontEnd/src/components/menu/Menu.tsx +++ b/FrontEnd/src/components/menu/Menu.tsx @@ -2,9 +2,9 @@ import { CSSProperties } from "react"; import classNames from "classnames"; import { useC, Text, ThemeColor } from "../common"; +import Icon from "../Icon"; import "./Menu.css"; -import Icon from "../Icon"; export type MenuItem = | { diff --git a/FrontEnd/src/components/menu/PopupMenu.tsx b/FrontEnd/src/components/menu/PopupMenu.tsx index 23a67f79..9d90799d 100644 --- a/FrontEnd/src/components/menu/PopupMenu.tsx +++ b/FrontEnd/src/components/menu/PopupMenu.tsx @@ -3,11 +3,9 @@ import classNames from "classnames"; import { createPortal } from "react-dom"; import { usePopper } from "react-popper"; -import { useClickOutside } from "~src/utilities/hooks"; - -import Menu, { MenuItems } from "./Menu"; - import { ThemeColor } from "../common"; +import { useClickOutside } from "../hooks"; +import Menu, { MenuItems } from "./Menu"; import "./PopupMenu.css"; diff --git a/FrontEnd/src/pages/login/index.tsx b/FrontEnd/src/pages/login/index.tsx index 582ebd0f..39ea3831 100644 --- a/FrontEnd/src/pages/login/index.tsx +++ b/FrontEnd/src/pages/login/index.tsx @@ -6,11 +6,7 @@ import { useUser, userService } from "~src/services/user"; import { useC } from "~src/components/common"; import LoadingButton from "~src/components/button/LoadingButton"; -import { - InputErrorDict, - InputGroup, - useInputs, -} from "~src/components/input/InputGroup"; +import { InputGroup, useInputs } from "~src/components/input/InputGroup"; import Page from "~src/components/Page"; import "./index.css"; @@ -47,15 +43,13 @@ export default function LoginPage() { label: "user.rememberMe", }, ], - validator: ({ username, password }) => { - const result: InputErrorDict = {}; + validator: ({ username, password }, errors) => { if (username === "") { - result["username"] = "login.emptyUsername"; + errors["username"] = "login.emptyUsername"; } if (password === "") { - result["password"] = "login.emptyPassword"; + errors["password"] = "login.emptyPassword"; } - return result; }, }, dataInit: {}, diff --git a/FrontEnd/src/pages/register/index.tsx b/FrontEnd/src/pages/register/index.tsx index 9e478612..fa25c2c2 100644 --- a/FrontEnd/src/pages/register/index.tsx +++ b/FrontEnd/src/pages/register/index.tsx @@ -7,11 +7,7 @@ import { getHttpTokenClient } from "~src/http/token"; import { userService, useUser } from "~src/services/user"; import { LoadingButton } from "~src/components/button"; -import { - useInputs, - InputErrorDict, - InputGroup, -} from "~src/components/input/InputGroup"; +import { useInputs, InputGroup } from "~src/components/input/InputGroup"; import "./index.css"; @@ -51,26 +47,22 @@ export default function RegisterPage() { label: "register.registerCode", }, ], - validator: ({ - username, - password, - confirmPassword, - registerCode, - }) => { - const result: InputErrorDict = {}; + validator: ( + { username, password, confirmPassword, registerCode }, + errors, + ) => { if (username === "") { - result["username"] = "register.error.usernameEmpty"; + errors["username"] = "register.error.usernameEmpty"; } if (password === "") { - result["password"] = "register.error.passwordEmpty"; + errors["password"] = "register.error.passwordEmpty"; } if (confirmPassword !== password) { - result["confirmPassword"] = "register.error.confirmPasswordWrong"; + errors["confirmPassword"] = "register.error.confirmPasswordWrong"; } if (registerCode === "") { - result["registerCode"] = "register.error.registerCodeEmpty"; + errors["registerCode"] = "register.error.registerCodeEmpty"; } - return result; }, }, dataInit: {}, diff --git a/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx b/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx index c34bcf4f..011c5059 100644 --- a/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx +++ b/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx @@ -11,9 +11,8 @@ import ImageCropper, { applyClipToImage, } from "~src/components/ImageCropper"; import BlobImage from "~src/components/BlobImage"; -import ButtonRowV2 from "~src/components/button/ButtonRowV2"; -import Dialog from "~src/components/dialog/Dialog"; -import DialogContainer from "~src/components/dialog/DialogContainer"; +import { ButtonRowV2 } from "~src/components/button"; +import { Dialog, DialogContainer } from "~src/components/dialog"; import "./ChangeAvatarDialog.css"; diff --git a/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx b/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx index bfcea92d..946b9fbe 100644 --- a/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx +++ b/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx @@ -3,9 +3,7 @@ import { useNavigate } from "react-router-dom"; import { userService } from "~src/services/user"; -import OperationDialog, { - InputErrorDict, -} from "~src/components/dialog/OperationDialog"; +import { OperationDialog } from "~src/components/dialog"; interface ChangePasswordDialogProps { open: boolean; @@ -47,21 +45,22 @@ export function ChangePasswordDialog(props: ChangePasswordDialogProps) { password: true, }, ], - validator: ({ oldPassword, newPassword, retypedNewPassword }) => { - const result: InputErrorDict = {}; + validator: ( + { oldPassword, newPassword, retypedNewPassword }, + errors, + ) => { if (oldPassword === "") { - result["oldPassword"] = + errors["oldPassword"] = "settings.dialogChangePassword.errorEmptyOldPassword"; } if (newPassword === "") { - result["newPassword"] = + errors["newPassword"] = "settings.dialogChangePassword.errorEmptyNewPassword"; } if (retypedNewPassword !== newPassword) { - result["retypedNewPassword"] = + errors["retypedNewPassword"] = "settings.dialogChangePassword.errorRetypeNotMatch"; } - return result; }, }} onProcess={async ({ oldPassword, newPassword }) => { diff --git a/FrontEnd/src/pages/setting/index.tsx b/FrontEnd/src/pages/setting/index.tsx index 67416a08..918a77b5 100644 --- a/FrontEnd/src/pages/setting/index.tsx +++ b/FrontEnd/src/pages/setting/index.tsx @@ -4,25 +4,26 @@ import { ReactNode, ComponentPropsWithoutRef, } from "react"; -import { useTranslation } from "react-i18next"; +import { useTranslation } from "react-i18next"; // For change language. import { useNavigate } from "react-router-dom"; import classNames from "classnames"; -import { useC, Text } from "~src/common"; import { useUser, userService } from "~src/services/user"; import { getHttpUserClient } from "~src/http/user"; +import { pushAlert } from "~src/services/alert"; + +import { useC, Text } from "~src/common"; -import { useDialog } from "~src/components/dialog"; -import ConfirmDialog from "~src/components/dialog/ConfirmDialog"; +import { useDialog, ConfirmDialog } from "~src/components/dialog"; import Card from "~src/components/Card"; import Spinner from "~src/components/Spinner"; import Page from "~src/components/Page"; + import ChangePasswordDialog from "./ChangePasswordDialog"; import ChangeAvatarDialog from "./ChangeAvatarDialog"; import ChangeNicknameDialog from "./ChangeNicknameDialog"; import "./index.css"; -import { pushAlert } from "~src/services/alert"; interface SettingSectionProps extends Omit, "title"> { diff --git a/FrontEnd/src/pages/timeline/Timeline.tsx b/FrontEnd/src/pages/timeline/Timeline.tsx index f266ec9d..caf4f502 100644 --- a/FrontEnd/src/pages/timeline/Timeline.tsx +++ b/FrontEnd/src/pages/timeline/Timeline.tsx @@ -1,6 +1,5 @@ import { useState, useEffect } from "react"; import classnames from "classnames"; -import { useScrollToBottom } from "~src/utilities/hooks"; import { HubConnectionState } from "@microsoft/signalr"; import { @@ -16,6 +15,8 @@ import { import { getTimelinePostUpdate$ } from "~src/services/timeline"; +import { useScrollToBottom } from "~src/components/hooks"; + import TimelinePostList from "./TimelinePostList"; import TimelinePostEdit from "./TimelinePostCreateView"; import TimelineCard from "./TimelineCard"; diff --git a/FrontEnd/src/pages/timeline/TimelineDeleteDialog.tsx b/FrontEnd/src/pages/timeline/TimelineDeleteDialog.tsx index 7b7b8e8c..a7209e75 100644 --- a/FrontEnd/src/pages/timeline/TimelineDeleteDialog.tsx +++ b/FrontEnd/src/pages/timeline/TimelineDeleteDialog.tsx @@ -39,9 +39,9 @@ const TimelineDeleteDialog: React.FC = (props) => { label: "", }, ], - validator: ({ name }) => { + validator: ({ name }, errors) => { if (name !== timeline.nameV2) { - return { name: "timeline.deleteDialog.notMatch" }; + errors.name = "timeline.deleteDialog.notMatch"; } }, }} diff --git a/FrontEnd/src/pages/timeline/TimelinePostView.tsx b/FrontEnd/src/pages/timeline/TimelinePostView.tsx index 2a8c5947..6b87ef2a 100644 --- a/FrontEnd/src/pages/timeline/TimelinePostView.tsx +++ b/FrontEnd/src/pages/timeline/TimelinePostView.tsx @@ -1,11 +1,13 @@ import { useState } from "react"; -import { getHttpTimelineClient, HttpTimelinePostInfo } from "~src/http/timeline"; +import { + getHttpTimelineClient, + HttpTimelinePostInfo, +} from "~src/http/timeline"; import { pushAlert } from "~src/services/alert"; -import { useClickOutside } from "~src/utilities/hooks"; - +import { useClickOutside } from "~src/components/hooks"; import UserAvatar from "~src/components/user/UserAvatar"; import { useDialog } from "~src/components/dialog"; import FlatButton from "~src/components/button/FlatButton"; diff --git a/FrontEnd/src/utilities/hooks.ts b/FrontEnd/src/utilities/hooks.ts deleted file mode 100644 index a59f7167..00000000 --- a/FrontEnd/src/utilities/hooks.ts +++ /dev/null @@ -1,5 +0,0 @@ -import useClickOutside from "./hooks/useClickOutside"; -import useScrollToBottom from "./hooks/useScrollToBottom"; -import { useIsSmallScreen } from "./hooks/mediaQuery"; - -export { useClickOutside, useScrollToBottom, useIsSmallScreen }; diff --git a/FrontEnd/src/utilities/hooks/mediaQuery.ts b/FrontEnd/src/utilities/hooks/mediaQuery.ts deleted file mode 100644 index ad55c3c0..00000000 --- a/FrontEnd/src/utilities/hooks/mediaQuery.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { useMediaQuery } from "react-responsive"; - -export function useIsSmallScreen(): boolean { - return useMediaQuery({ maxWidth: 576 }); -} diff --git a/FrontEnd/src/utilities/hooks/useClickOutside.ts b/FrontEnd/src/utilities/hooks/useClickOutside.ts deleted file mode 100644 index 6dcbf7b3..00000000 --- a/FrontEnd/src/utilities/hooks/useClickOutside.ts +++ /dev/null @@ -1,38 +0,0 @@ -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/utilities/hooks/useScrollToBottom.ts b/FrontEnd/src/utilities/hooks/useScrollToBottom.ts deleted file mode 100644 index 216746f4..00000000 --- a/FrontEnd/src/utilities/hooks/useScrollToBottom.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { useRef, useEffect } from "react"; -import { fromEvent } from "rxjs"; -import { filter, throttleTime } from "rxjs/operators"; - -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; -- cgit v1.2.3 From b66a57071316434356e77e294ec22181e4db54d5 Mon Sep 17 00:00:00 2001 From: crupest Date: Mon, 28 Aug 2023 01:41:41 +0800 Subject: ... --- FrontEnd/src/components/BlobImage.tsx | 15 +- FrontEnd/src/components/ImageCropper.css | 15 +- FrontEnd/src/components/ImageCropper.tsx | 564 +++++++++++++--------- FrontEnd/src/components/LoadFailReload.tsx | 37 ++ FrontEnd/src/pages/setting/ChangeAvatarDialog.css | 1 + FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx | 34 +- FrontEnd/src/pages/timeline/TimelineCard.tsx | 2 +- 7 files changed, 394 insertions(+), 274 deletions(-) create mode 100644 FrontEnd/src/components/LoadFailReload.tsx (limited to 'FrontEnd/src/components') diff --git a/FrontEnd/src/components/BlobImage.tsx b/FrontEnd/src/components/BlobImage.tsx index 259c2210..cccf2f74 100644 --- a/FrontEnd/src/components/BlobImage.tsx +++ b/FrontEnd/src/components/BlobImage.tsx @@ -1,12 +1,13 @@ -import { ComponentPropsWithoutRef, useState, useEffect } from "react"; +import { ComponentPropsWithoutRef, useState, useEffect, useMemo } from "react"; type BlobImageProps = Omit, "src"> & { imgRef?: React.Ref; src?: Blob | string | null; + keyBySrc?: boolean; }; export default function BlobImage(props: BlobImageProps) { - const { imgRef, src, ...otherProps } = props; + const { imgRef, src, keyBySrc, ...otherProps } = props; const [url, setUrl] = useState(undefined); @@ -22,5 +23,13 @@ export default function BlobImage(props: BlobImageProps) { } }, [src]); - return ; + const key = useMemo(() => { + if (keyBySrc) { + return url == null ? undefined : btoa(url); + } else { + return undefined; + } + }, [url, keyBySrc]); + + return ; } diff --git a/FrontEnd/src/components/ImageCropper.css b/FrontEnd/src/components/ImageCropper.css index 2c4d0a8c..9631cf1d 100644 --- a/FrontEnd/src/components/ImageCropper.css +++ b/FrontEnd/src/components/ImageCropper.css @@ -1,18 +1,17 @@ -.image-cropper-container { +.cru-image-cropper-container { position: relative; box-sizing: border-box; user-select: none; } -.image-cropper-container img { - position: absolute; +.cru-image-cropper-container img { left: 0; top: 0; width: 100%; height: 100%; } -.image-cropper-mask-container { +.cru-image-cropper-mask-container { position: absolute; left: 0; top: 0; @@ -21,18 +20,16 @@ overflow: hidden; } -.image-cropper-mask { +.cru-image-cropper-mask { position: absolute; box-shadow: 0 0 0 10000px rgba(255, 255, 255, 0.8); touch-action: none; } -.image-cropper-handler { +.cru-image-cropper-handler { position: absolute; - width: 26px; - height: 26px; 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 index f23994e2..1f530004 100644 --- a/FrontEnd/src/components/ImageCropper.tsx +++ b/FrontEnd/src/components/ImageCropper.tsx @@ -1,312 +1,396 @@ -import * as React from "react"; +import { + useState, + useRef, + SyntheticEvent, + PointerEvent, + useMemo, + MutableRefObject, +} from "react"; import classnames from "classnames"; -import { UiLogicError } from "~src/common"; +import { UiLogicError } from "./common"; +import BlobImage from "./BlobImage"; import "./ImageCropper.css"; -import BlobImage from "./BlobImage"; +// All in natural size of image. export interface Clip { left: number; top: number; width: number; + height: number; } -interface NormailizedClip extends Clip { - height: number; +export function applyClipToImage( + image: HTMLImageElement, + clip: Clip, + mimeType: string, +): Promise { + 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 Movement { + x: number; + y: number; } interface ImageInfo { + element: HTMLImageElement; width: number; height: number; - landscape: boolean; ratio: number; - maxClipWidth: number; - maxClipHeight: number; + landscape: boolean; } -interface ImageCropperSavedState { - clip: NormailizedClip; - x: number; - y: number; - pointerId: number; +export interface CropConstraint { + ratio?: number; + // minClipWidth?: number; + // minClipHeight?: number; + // maxClipWidth?: number; + // maxClipHeight?: number; } -export interface ImageCropperProps { - clip: Clip | null; - image: string | Blob; - onChange: (clip: Clip) => void; - imageElementCallback?: (element: HTMLImageElement | null) => void; - className?: string; +function generateImageInfo( + imageElement: HTMLImageElement | null, +): ImageInfo | null { + if (imageElement == null) return null; + + const { naturalWidth, naturalHeight } = imageElement; + const imageRatio = naturalHeight / naturalWidth; + + return { + element: imageElement, + width: naturalWidth, + height: naturalHeight, + ratio: imageRatio, + landscape: imageRatio < 1, + }; } -const ImageCropper = (props: ImageCropperProps): React.ReactElement => { - const { clip, image, onChange, imageElementCallback, className } = props; +const emptyClip: Clip = { + left: 0, + top: 0, + width: 0, + height: 0, +}; - const [oldState, setOldState] = React.useState( - null, - ); - const [imageInfo, setImageInfo] = React.useState(null); +const allClip : Clip = { + left: 0, + top: 0, + width: Number.MAX_VALUE, + height: Number.MAX_VALUE, +} - const normalizeClip = (c: Clip | null | undefined): NormailizedClip => { - if (c == null) { - return { left: 0, top: 0, width: 0, height: 0 }; +// TODO: Continue here... mode... +function adjustClip( + clip: Clip, + mode: "move" | "resize" | "both", + imageSize: { width: number; height: number }, + targetRatio?: number | null | undefined, +): Clip { + class ClipGeometry { + constructor( + public left: number, + public top: number, + public width: number, + public height: number, + ) {} + + get right(): number { + return this.left + this.width; } - return { - left: c.left || 0, - top: c.top || 0, - width: c.width || 0, - height: imageInfo != null ? (c.width || 0) / imageInfo.ratio : 0, - }; - }; + set right(value: number) { + this.width = this.left + value; + } - const c = normalizeClip(clip); + get bottom(): number { + return this.top + this.height; + } - const imgElementRef = React.useRef(null); + set bottom(value: number) { + this.height = this.top + value; + } - const onImageRef = React.useCallback( - (e: HTMLImageElement | null) => { - imgElementRef.current = e; - if (imageElementCallback != null && e == null) { - imageElementCallback(null); - } - }, - [imageElementCallback], - ); + get ratio(): number { + return this.height / this.width; + } - const onImageLoad = React.useCallback( - (e: React.SyntheticEvent) => { - const img = e.currentTarget; - const landscape = img.naturalWidth >= img.naturalHeight; - - const info = { - width: img.naturalWidth, - height: img.naturalHeight, - landscape, - ratio: img.naturalHeight / img.naturalWidth, - maxClipWidth: landscape ? img.naturalHeight / img.naturalWidth : 1, - maxClipHeight: landscape ? 1 : img.naturalWidth / img.naturalHeight, + toClip(): Clip { + return { + left: this.left, + top: this.top, + width: this.width, + height: this.height, }; - setImageInfo(info); - onChange({ left: 0, top: 0, width: info.maxClipWidth }); - if (imageElementCallback != null) { - imageElementCallback(img); - } - }, - [onChange, imageElementCallback], - ); + } + } - const onPointerDown = React.useCallback( - (e: React.PointerEvent) => { - if (oldState != null) return; - e.currentTarget.setPointerCapture(e.pointerId); - setOldState({ - x: e.clientX, - y: e.clientY, - clip: c, - pointerId: e.pointerId, - }); - }, - [oldState, c], + const clipGeometry = new ClipGeometry( + clip.left, + clip.top, + clip.width, + clip.height, ); - const onPointerUp = React.useCallback( - (e: React.PointerEvent) => { - if (oldState == null || oldState.pointerId !== e.pointerId) return; - e.currentTarget.releasePointerCapture(e.pointerId); - setOldState(null); - }, - [oldState], - ); - - const onPointerMove = React.useCallback( - (e: React.PointerEvent) => { - if (oldState == null) return; - - const oldClip = oldState.clip; - - const movement = { x: e.clientX - oldState.x, y: e.clientY - oldState.y }; - - const { current: imgElement } = imgElementRef; - - if (imgElement == null) throw new UiLogicError("Image element is null."); + // Make clip in image. + clipGeometry.left = Math.max(clipGeometry.left, 0); + clipGeometry.top = Math.max(clipGeometry.top, 0); + clipGeometry.right = Math.min(clipGeometry.right, imageSize.width); + clipGeometry.bottom = Math.min(clipGeometry.bottom, imageSize.height); + + // Make image "positive" + if (clipGeometry.right < clipGeometry.left) { + clipGeometry.right = clipGeometry.left; + } + if (clipGeometry.bottom < clipGeometry.top) { + clipGeometry.bottom = clipGeometry.top; + } + + // Now correct ratio + const currentRatio = clipGeometry.ratio; + if (targetRatio != null && targetRatio > 0 && currentRatio !== targetRatio) { + if (currentRatio < targetRatio) { + // too wide + clipGeometry.width = clipGeometry.height / targetRatio; + } else { + clipGeometry.height = clipGeometry.width * targetRatio; + } + } - const moveRatio = { - x: movement.x / imgElement.width, - y: movement.y / imgElement.height, - }; + return clipGeometry.toClip(); +} - const newRatio = { - x: oldClip.left + moveRatio.x, - y: oldClip.top + moveRatio.y, - }; - if (newRatio.x < 0) { - newRatio.x = 0; - } else if (newRatio.x > 1 - oldClip.width) { - newRatio.x = 1 - oldClip.width; - } - if (newRatio.y < 0) { - newRatio.y = 0; - } else if (newRatio.y > 1 - oldClip.height) { - newRatio.y = 1 - oldClip.height; - } +interface ImageCropperProps { + clip: Clip; + image: Blob | string | null; + imageElementRef: MutableRefObject; + onImageLoad: (event: SyntheticEvent) => void; + onMove: (movement: Movement) => void; + onResize: (movement: Movement) => void; + containerClassName?: string; +} - onChange({ left: newRatio.x, top: newRatio.y, width: oldClip.width }); +export function useImageCrop( + file: File | null, + options?: { + constraint?: CropConstraint; + }, +): { + clip: Clip; + setClip: (clip: Clip) => void; + canCrop: boolean; + crop: () => Promise; + imageCropperProps: ImageCropperProps; +} { + const targetRatio = options?.constraint?.ratio; + + const imageElementRef = useRef(null); + const [image, setImage] = useState(null); + const [clip, setClip] = useState(emptyClip ); + + if (imageElementRef.current == null && image != null) { + setImage(null); + setClip(emptyClip); + } + + const canCrop = file != null && image != null; + + const adjustedClip = useMemo(() => { + return image == null ? emptyClip : adjustClip(clip, image, targetRatio); + }, [clip, image, targetRatio]); + + return { + clip, + setClip, + canCrop, + crop() { + if (!canCrop) throw new UiLogicError(); + return applyClipToImage(image.element, adjustedClip, file.type); }, - [oldState, onChange], - ); - - const onHandlerPointerMove = React.useCallback( - (e: React.PointerEvent) => { - if (oldState == null) return; + imageCropperProps: { + clip: adjustedClip, + image: file, + imageElementRef: imageElementRef, + // TODO: Continue here... + onMove: , + onResize: , + onImageLoad: () => { + const image = generateImageInfo(imageElementRef.current); + setImage(image); + setClip(adjustClip(allClip, "both", image, targetRatio)); + }, + }, + }; +} - const oldClip = oldState.clip; +interface PointerState { + x: number; + y: number; + pointerId: number; +} - const movement = { x: e.clientX - oldState.x, y: e.clientY - oldState.y }; +const imageCropperHandlerSize = 15; - const ratio = imageInfo == null ? 1 : imageInfo.ratio; +export function ImageCropper(props: ImageCropperProps) { + function convertClipToElement( + clip: Clip, + imageElement: HTMLImageElement, + ): Clip { + const xRatio = imageElement.clientWidth / imageElement.naturalWidth; + const yRatio = imageElement.clientHeight / imageElement.naturalHeight; + return { + 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, + imageElementRef, + onImageLoad, + onMove, + onResize, + containerClassName, + } = props; + + const pointerStateRef = useRef(null); + + const clipInElement = + imageElementRef.current != null + ? convertClipToElement(clip, imageElementRef.current) + : emptyClip; + + const actOnMovement = ( + e: PointerEvent, + change: (movement: Movement) => void, + ) => { + if ( + imageElementRef.current == null || + pointerStateRef.current == null || + pointerStateRef.current.pointerId != e.pointerId + ) { + return; + } - const { current: imgElement } = imgElementRef; + const { x, y } = pointerStateRef.current; - if (imgElement == null) throw new UiLogicError("Image element is null."); + const movement = { + x: e.clientX - x, + y: e.clientY - y, + }; - const moveRatio = { - x: movement.x / imgElement.width, - y: movement.x / imgElement.width / ratio, - }; + change(movement); + }; - const newRatio = { - x: oldClip.width + moveRatio.x, - y: oldClip.height + moveRatio.y, - }; + const onPointerDown = (e: PointerEvent) => { + if (imageElementRef.current == null || pointerStateRef.current != null) + return; - const maxRatio = { - x: Math.min(1 - oldClip.left, newRatio.x), - y: Math.min(1 - oldClip.top, newRatio.y), - }; + e.currentTarget.setPointerCapture(e.pointerId); - const maxWidthRatio = Math.min(maxRatio.x, maxRatio.y * ratio); + pointerStateRef.current = { + x: e.clientX, + y: e.clientY, + pointerId: e.pointerId, + }; + }; - let newWidth; - if (newRatio.x < 0) { - newWidth = 0; - } else if (newRatio.x > maxWidthRatio) { - newWidth = maxWidthRatio; - } else { - newWidth = newRatio.x; - } + const onPointerUp = (e: PointerEvent) => { + if ( + pointerStateRef.current == null || + pointerStateRef.current.pointerId != e.pointerId + ) { + return; + } - onChange({ left: oldClip.left, top: oldClip.top, width: newWidth }); - }, - [imageInfo, oldState, onChange], - ); + e.currentTarget.releasePointerCapture(e.pointerId); + pointerStateRef.current = null; + }; - const toPercentage = (n: number): string => `${n}%`; + const onMaskPointerMove = (e: PointerEvent) => { + actOnMovement(e, onMove); + }; - // fuck!!! I just can't find a better way to implement this in pure css - const containerStyle: React.CSSProperties = (() => { - if (imageInfo == null) { - return { width: "100%", paddingTop: "100%", height: 0 }; - } else { - if (imageInfo.ratio > 1) { - return { - width: toPercentage(100 / imageInfo.ratio), - paddingTop: "100%", - height: 0, - }; - } else { - return { - width: "100%", - paddingTop: toPercentage(100 * imageInfo.ratio), - height: 0, - }; - } - } - })(); + const onResizeHandlerPointerMove = (e: PointerEvent) => { + actOnMovement(e, onResize); + }; return (
- -
+ +
); -}; - -export default ImageCropper; - -export function applyClipToImage( - image: HTMLImageElement, - clip: Clip, - mimeType: string, -): Promise { - return new Promise((resolve, reject) => { - const naturalSize = { - width: image.naturalWidth, - height: image.naturalHeight, - }; - const clipArea = { - x: naturalSize.width * clip.left, - y: naturalSize.height * clip.top, - length: naturalSize.width * clip.width, - }; - - const canvas = document.createElement("canvas"); - canvas.width = clipArea.length; - canvas.height = clipArea.length; - const context = canvas.getContext("2d"); - - if (context == null) throw new Error("Failed to create context."); - - context.drawImage( - image, - clipArea.x, - clipArea.y, - clipArea.length, - clipArea.length, - 0, - 0, - clipArea.length, - clipArea.length, - ); - - canvas.toBlob((blob) => { - if (blob == null) { - reject(new Error("canvas.toBlob returns null")); - } else { - resolve(blob); - } - }, mimeType); - }); } diff --git a/FrontEnd/src/components/LoadFailReload.tsx b/FrontEnd/src/components/LoadFailReload.tsx new file mode 100644 index 00000000..81ba1f67 --- /dev/null +++ b/FrontEnd/src/components/LoadFailReload.tsx @@ -0,0 +1,37 @@ +import * as React from "react"; +import { Trans } from "react-i18next"; + +export interface LoadFailReloadProps { + className?: string; + style?: React.CSSProperties; + onReload: () => void; +} + +const LoadFailReload: React.FC = ({ + onReload, + className, + style, +}) => { + return ( + + 0 + { + onReload(); + e.preventDefault(); + }} + > + 1 + + 2 + + ); +}; + +export default LoadFailReload; diff --git a/FrontEnd/src/pages/setting/ChangeAvatarDialog.css b/FrontEnd/src/pages/setting/ChangeAvatarDialog.css index 2aa0bb54..c9eb8011 100644 --- a/FrontEnd/src/pages/setting/ChangeAvatarDialog.css +++ b/FrontEnd/src/pages/setting/ChangeAvatarDialog.css @@ -11,6 +11,7 @@ } .change-avatar-cropper { + max-width: 400px; max-height: 400px; } diff --git a/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx b/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx index 011c5059..2fcfef2c 100644 --- a/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx +++ b/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx @@ -6,10 +6,7 @@ import { useUser } from "~src/services/user"; import { getHttpUserClient } from "~src/http/user"; -import ImageCropper, { - Clip, - applyClipToImage, -} from "~src/components/ImageCropper"; +import { ImageCropper, useImageCrop } from "~src/components/ImageCropper"; import BlobImage from "~src/components/BlobImage"; import { ButtonRowV2 } from "~src/components/button"; import { Dialog, DialogContainer } from "~src/components/dialog"; @@ -40,10 +37,13 @@ export default function ChangeAvatarDialog({ const [state, setState] = useState("select"); const [file, setFile] = useState(null); - const [clip, setClip] = useState(null); - const [cropImgElement, setCropImgElement] = useState( - null, - ); + + const { canCrop, crop, imageCropperProps } = useImageCrop(file, { + constraint: { + ratio: 1, + }, + }); + const [resultBlob, setResultBlob] = useState(null); const [message, setMessage] = useState( "settings.dialogChangeAvatar.prompt.select", @@ -65,18 +65,13 @@ export default function ChangeAvatarDialog({ }; const onCropNext = () => { - if ( - cropImgElement == null || - clip == null || - clip.width === 0 || - file == null - ) { + if (!canCrop) { throw new UiLogicError(); } setState("process-crop"); - void applyClipToImage(cropImgElement, clip, file.type).then((b) => { + void crop().then((b) => { setState("preview"); setResultBlob(b); }); @@ -151,7 +146,7 @@ export default function ChangeAvatarDialog({ action: "primary", text: "operationDialog.nextStep", onClick: onCropNext, - disabled: cropImgElement == null || clip == null || clip.width === 0, + disabled: !canCrop, }, ], "process-crop": [cancelButton, createPreviousButton(onPreviewPrevious)], @@ -214,11 +209,8 @@ export default function ChangeAvatarDialog({ return (
{c("settings.dialogChangeAvatar.prompt.crop")} diff --git a/FrontEnd/src/pages/timeline/TimelineCard.tsx b/FrontEnd/src/pages/timeline/TimelineCard.tsx index 82d6d350..f17a3ce9 100644 --- a/FrontEnd/src/pages/timeline/TimelineCard.tsx +++ b/FrontEnd/src/pages/timeline/TimelineCard.tsx @@ -7,7 +7,7 @@ import { pushAlert } from "~src/services/alert"; import { HttpTimelineInfo } from "~src/http/timeline"; import { getHttpBookmarkClient } from "~src/http/bookmark"; -import { useMobile } from "~src/components/common"; +import { useMobile } from "~src/components/hooks"; import { Dialog, useDialog } from "~src/components/dialog"; import UserAvatar from "~src/components/user/UserAvatar"; import PopupMenu from "~src/components/menu/PopupMenu"; -- cgit v1.2.3 From 877f4ff87c39e3484ae2e7e6c920fc7fb8c04c23 Mon Sep 17 00:00:00 2001 From: crupest Date: Mon, 28 Aug 2023 21:14:20 +0800 Subject: ... --- FrontEnd/src/components/ImageCropper.tsx | 262 ++++++++++------------------ FrontEnd/src/components/common.ts | 2 + FrontEnd/src/utilities/geometry.ts | 291 +++++++++++++++++++++++++++++++ 3 files changed, 388 insertions(+), 167 deletions(-) create mode 100644 FrontEnd/src/utilities/geometry.ts (limited to 'FrontEnd/src/components') diff --git a/FrontEnd/src/components/ImageCropper.tsx b/FrontEnd/src/components/ImageCropper.tsx index 1f530004..acbbbe0c 100644 --- a/FrontEnd/src/components/ImageCropper.tsx +++ b/FrontEnd/src/components/ImageCropper.tsx @@ -1,29 +1,20 @@ -import { - useState, - useRef, - SyntheticEvent, - PointerEvent, - useMemo, - MutableRefObject, -} from "react"; +import { useState, useRef, PointerEvent } from "react"; import classnames from "classnames"; -import { UiLogicError } from "./common"; +import { UiLogicError, geometry } from "./common"; + import BlobImage from "./BlobImage"; import "./ImageCropper.css"; -// All in natural size of image. -export interface Clip { - left: number; - top: number; - width: number; - height: number; -} +const { Rect } = geometry; + +type Rect = geometry.Rect; +type Movement = geometry.Movement; -export function applyClipToImage( +export function crop( image: HTMLImageElement, - clip: Clip, + clip: Rect, mimeType: string, ): Promise { return new Promise((resolve, reject) => { @@ -56,17 +47,13 @@ export function applyClipToImage( }); } -interface Movement { - x: number; - y: number; -} - interface ImageInfo { element: HTMLImageElement; width: number; height: number; ratio: number; landscape: boolean; + rect: Rect; } export interface CropConstraint { @@ -77,11 +64,7 @@ export interface CropConstraint { // maxClipHeight?: number; } -function generateImageInfo( - imageElement: HTMLImageElement | null, -): ImageInfo | null { - if (imageElement == null) return null; - +function generateImageInfo(imageElement: HTMLImageElement): ImageInfo { const { naturalWidth, naturalHeight } = imageElement; const imageRatio = naturalHeight / naturalWidth; @@ -91,108 +74,15 @@ function generateImageInfo( height: naturalHeight, ratio: imageRatio, landscape: imageRatio < 1, + rect: new Rect(0, 0, naturalWidth, naturalHeight), }; } -const emptyClip: Clip = { - left: 0, - top: 0, - width: 0, - height: 0, -}; - -const allClip : Clip = { - left: 0, - top: 0, - width: Number.MAX_VALUE, - height: Number.MAX_VALUE, -} - -// TODO: Continue here... mode... -function adjustClip( - clip: Clip, - mode: "move" | "resize" | "both", - imageSize: { width: number; height: number }, - targetRatio?: number | null | undefined, -): Clip { - class ClipGeometry { - constructor( - public left: number, - public top: number, - public width: number, - public height: number, - ) {} - - get right(): number { - return this.left + this.width; - } - - set right(value: number) { - this.width = this.left + value; - } - - get bottom(): number { - return this.top + this.height; - } - - set bottom(value: number) { - this.height = this.top + value; - } - - get ratio(): number { - return this.height / this.width; - } - - toClip(): Clip { - return { - left: this.left, - top: this.top, - width: this.width, - height: this.height, - }; - } - } - - const clipGeometry = new ClipGeometry( - clip.left, - clip.top, - clip.width, - clip.height, - ); - - // Make clip in image. - clipGeometry.left = Math.max(clipGeometry.left, 0); - clipGeometry.top = Math.max(clipGeometry.top, 0); - clipGeometry.right = Math.min(clipGeometry.right, imageSize.width); - clipGeometry.bottom = Math.min(clipGeometry.bottom, imageSize.height); - - // Make image "positive" - if (clipGeometry.right < clipGeometry.left) { - clipGeometry.right = clipGeometry.left; - } - if (clipGeometry.bottom < clipGeometry.top) { - clipGeometry.bottom = clipGeometry.top; - } - - // Now correct ratio - const currentRatio = clipGeometry.ratio; - if (targetRatio != null && targetRatio > 0 && currentRatio !== targetRatio) { - if (currentRatio < targetRatio) { - // too wide - clipGeometry.width = clipGeometry.height / targetRatio; - } else { - clipGeometry.height = clipGeometry.width * targetRatio; - } - } - - return clipGeometry.toClip(); -} - interface ImageCropperProps { - clip: Clip; + clip: Rect; image: Blob | string | null; - imageElementRef: MutableRefObject; - onImageLoad: (event: SyntheticEvent) => void; + imageElementCallback: (element: HTMLImageElement | null) => void; + onImageLoad: () => void; onMove: (movement: Movement) => void; onResize: (movement: Movement) => void; containerClassName?: string; @@ -204,28 +94,26 @@ export function useImageCrop( constraint?: CropConstraint; }, ): { - clip: Clip; - setClip: (clip: Clip) => void; + clip: Rect; + setClip: (clip: Rect) => void; canCrop: boolean; crop: () => Promise; imageCropperProps: ImageCropperProps; } { const targetRatio = options?.constraint?.ratio; - const imageElementRef = useRef(null); - const [image, setImage] = useState(null); - const [clip, setClip] = useState(emptyClip ); + const [imageElement, setImageElement] = useState( + null, + ); + const [imageInfo, setImageInfo] = useState(null); + const [clip, setClip] = useState(Rect.empty); - if (imageElementRef.current == null && image != null) { - setImage(null); - setClip(emptyClip); + if (imageElement == null && imageInfo != null) { + setImageInfo(null); + setClip(Rect.empty); } - const canCrop = file != null && image != null; - - const adjustedClip = useMemo(() => { - return image == null ? emptyClip : adjustClip(clip, image, targetRatio); - }, [clip, image, targetRatio]); + const canCrop = file != null && imageElement != null && imageInfo != null; return { clip, @@ -233,19 +121,43 @@ export function useImageCrop( canCrop, crop() { if (!canCrop) throw new UiLogicError(); - return applyClipToImage(image.element, adjustedClip, file.type); + return crop(imageElement, clip, file.type); }, imageCropperProps: { - clip: adjustedClip, + clip, image: file, - imageElementRef: imageElementRef, - // TODO: Continue here... - onMove: , - onResize: , + imageElementCallback: setImageElement, + onMove: (movement) => { + if (imageInfo == null) return; + const newClip = geometry.adjustRectToContainer( + clip.copy().move(movement), + imageInfo.rect, + "move", + { + targetRatio, + }, + ); + setClip(newClip); + }, + onResize: (movement) => { + if (imageInfo == null) return; + const newClip = geometry.adjustRectToContainer( + clip.copy().expand(movement), + imageInfo.rect, + "resize", + { targetRatio, resizeNoFlip: true, ratioCorrectBasedOn: "width" }, + ); + setClip(newClip); + }, onImageLoad: () => { - const image = generateImageInfo(imageElementRef.current); - setImage(image); - setClip(adjustClip(allClip, "both", image, targetRatio)); + if (imageElement == null) throw new UiLogicError(); + const image = generateImageInfo(imageElement); + setImageInfo(image); + setClip( + geometry.adjustRectToContainer(Rect.max, image.rect, "both", { + targetRatio, + }), + ); }, }, }; @@ -261,17 +173,17 @@ const imageCropperHandlerSize = 15; export function ImageCropper(props: ImageCropperProps) { function convertClipToElement( - clip: Clip, + clip: Rect, imageElement: HTMLImageElement, - ): Clip { + ): Rect { const xRatio = imageElement.clientWidth / imageElement.naturalWidth; const yRatio = imageElement.clientHeight / imageElement.naturalHeight; - return { + return Rect.from({ left: xRatio * clip.left, top: yRatio * clip.top, width: xRatio * clip.width, height: yRatio * clip.height, - }; + }); } function convertMovementFromElement( @@ -289,7 +201,7 @@ export function ImageCropper(props: ImageCropperProps) { const { clip, image, - imageElementRef, + imageElementCallback, onImageLoad, onMove, onResize, @@ -297,18 +209,21 @@ export function ImageCropper(props: ImageCropperProps) { } = props; const pointerStateRef = useRef(null); + const [imageElement, setImageElement] = useState( + null, + ); - const clipInElement = - imageElementRef.current != null - ? convertClipToElement(clip, imageElementRef.current) - : emptyClip; + const clipInElement: Rect = + imageElement != null + ? convertClipToElement(clip, imageElement) + : Rect.empty; const actOnMovement = ( e: PointerEvent, change: (movement: Movement) => void, ) => { if ( - imageElementRef.current == null || + imageElement == null || pointerStateRef.current == null || pointerStateRef.current.pointerId != e.pointerId ) { @@ -322,12 +237,14 @@ export function ImageCropper(props: ImageCropperProps) { y: e.clientY - y, }; - change(movement); + pointerStateRef.current.x = e.clientX; + pointerStateRef.current.y = e.clientY; + + change(convertMovementFromElement(movement, imageElement)); }; const onPointerDown = (e: PointerEvent) => { - if (imageElementRef.current == null || pointerStateRef.current != null) - return; + if (imageElement == null || pointerStateRef.current != null) return; e.currentTarget.setPointerCapture(e.pointerId); @@ -362,16 +279,27 @@ export function ImageCropper(props: ImageCropperProps) {
- + { + setImageElement(element); + imageElementCallback(element); + }} + src={image} + onLoad={onImageLoad} + />
= 0 ? this.left : this.right; + } + + get normalizedTop(): number { + return this.height >= 0 ? this.top : this.bottom; + } + + get normalizedRight(): number { + return this.width >= 0 ? this.right : this.left; + } + + get normalizedBottom(): number { + return this.height >= 0 ? this.bottom : this.top; + } + + get normalizedWidth(): number { + return Math.abs(this.width); + } + + get normalizedHeight(): number { + return Math.abs(this.height); + } + + get normalizedSize(): Size { + return { + width: this.normalizedWidth, + height: this.normalizedHeight, + }; + } + + get normalizedRatio(): number { + return Math.abs(this.ratio); + } + + normalize(): Rect { + if (this.width < 0) { + this.width = -this.width; + this.left -= this.width; + } + if (this.height < 0) { + this.height = -this.height; + this.top -= this.height; + } + return this; + } + + move(movement: Movement): Rect { + this.left += movement.x; + this.top += movement.y; + return this; + } + + expand(size: Size | Point): Rect { + if ("x" in size) { + this.width += size.x; + this.height += size.y; + } else { + this.width += size.width; + this.height += size.height; + } + return this; + } + + copy(): Rect { + return new Rect(this.left, this.top, this.width, this.height); + } +} + +export function adjustRectToContainer( + rect: Rect, + container: Rect, + mode: "move" | "resize" | "both", + options?: { + targetRatio?: number; + resizeNoFlip?: boolean; + ratioCorrectBasedOn?: "bigger" | "smaller" | "width" | "height"; + }, +): Rect { + rect = rect.copy(); + container = container.copy().normalize(); + + if (process.env.NODE_ENV === "development") { + if (mode === "move") { + if (rect.normalizedWidth > container.width) { + console.warn( + "adjust rect (move): rect.normalizedWidth > container.normalizedWidth", + ); + } + if (rect.normalizedHeight > container.height) { + console.warn( + "adjust rect (move): rect.normalizedHeight > container.normalizedHeight", + ); + } + } + if (mode === "resize") { + if (rect.left < container.left) { + console.warn( + "adjust rect (resize): rect.left < container.normalizedLeft", + ); + } + if (rect.left > container.right) { + console.warn( + "adjust rect (resize): rect.left > container.normalizedRight", + ); + } + if (rect.top < container.top) { + console.warn( + "adjust rect (resize): rect.top < container.normalizedTop", + ); + } + if (rect.top > container.bottom) { + console.warn( + "adjust rect (resize): rect.top > container.normalizedBottom", + ); + } + } + } + + if (mode === "move") { + rect.left = + rect.width >= 0 + ? clamp(rect.left, container.left, container.right - rect.width) + : clamp(rect.left, container.left - rect.width, container.right); + rect.top = + rect.height >= 0 + ? clamp(rect.top, container.top, container.bottom - rect.height) + : clamp(rect.top, container.top - rect.height, container.bottom); + } else if (mode === "resize") { + const noFlip = options?.resizeNoFlip; + rect.right = clamp( + rect.right, + noFlip ? 0 : container.left, + container.right, + ); + rect.bottom = clamp( + rect.bottom, + noFlip ? 0 : container.top, + container.bottom, + ); + } else { + rect.left = clamp(rect.left, container.left, container.right); + rect.top = clamp(rect.top, container.top, container.bottom); + rect.right = clamp(rect.right, container.left, container.right); + rect.bottom = clamp(rect.bottom, container.top, container.bottom); + } + + // Now correct ratio + const currentRatio = rect.normalizedRatio; + let targetRatio = options?.targetRatio; + if (targetRatio != null) targetRatio = Math.abs(targetRatio); + if (targetRatio != null && currentRatio !== targetRatio) { + const { ratioCorrectBasedOn } = options ?? {}; + + const newWidth = + (Math.sign(rect.width) * rect.normalizedHeight) / targetRatio; + const newHeight = + Math.sign(rect.height) * rect.normalizedWidth * targetRatio; + + const newBottom = rect.top + newHeight; + const newRight = rect.left + newWidth; + + if (ratioCorrectBasedOn === "width") { + if (newBottom >= container.top && newBottom <= container.bottom) { + rect.height = newHeight; + } else { + rect.bottom = clamp(newBottom, container.top, container.bottom); + rect.width = + (Math.sign(rect.width) * rect.normalizedHeight) / targetRatio; + } + } else if (ratioCorrectBasedOn === "height") { + if (newRight >= container.left && newRight <= container.right) { + rect.width = newWidth; + } else { + rect.right = clamp(newRight, container.left, container.right); + rect.height = + Math.sign(rect.height) * rect.normalizedWidth * targetRatio; + } + } else if (ratioCorrectBasedOn === "smaller") { + if (currentRatio > targetRatio) { + // too tall + rect.width = + (Math.sign(rect.width) * rect.normalizedHeight) / targetRatio; + } else { + rect.height = + Math.sign(rect.height) * rect.normalizedWidth * targetRatio; + } + } else { + if (currentRatio < targetRatio) { + // too wide + rect.width = + (Math.sign(rect.width) * rect.normalizedHeight) / targetRatio; + } else { + rect.height = + Math.sign(rect.height) * rect.normalizedWidth * targetRatio; + } + } + } + + return rect; +} -- cgit v1.2.3 From 8236f228557f5305aed8a04359cd05ad8db4a8ed Mon Sep 17 00:00:00 2001 From: crupest Date: Mon, 28 Aug 2023 21:19:24 +0800 Subject: ... --- FrontEnd/src/components/ImageCropper.tsx | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) (limited to 'FrontEnd/src/components') diff --git a/FrontEnd/src/components/ImageCropper.tsx b/FrontEnd/src/components/ImageCropper.tsx index acbbbe0c..4dfdd0cd 100644 --- a/FrontEnd/src/components/ImageCropper.tsx +++ b/FrontEnd/src/components/ImageCropper.tsx @@ -83,8 +83,8 @@ interface ImageCropperProps { image: Blob | string | null; imageElementCallback: (element: HTMLImageElement | null) => void; onImageLoad: () => void; - onMove: (movement: Movement) => void; - onResize: (movement: Movement) => void; + onMove: (movement: Movement, originalClip: Rect) => void; + onResize: (movement: Movement, originalClip: Rect) => void; containerClassName?: string; } @@ -127,10 +127,10 @@ export function useImageCrop( clip, image: file, imageElementCallback: setImageElement, - onMove: (movement) => { + onMove: (movement, originalClip) => { if (imageInfo == null) return; const newClip = geometry.adjustRectToContainer( - clip.copy().move(movement), + originalClip.copy().move(movement), imageInfo.rect, "move", { @@ -139,10 +139,10 @@ export function useImageCrop( ); setClip(newClip); }, - onResize: (movement) => { + onResize: (movement, originalClip) => { if (imageInfo == null) return; const newClip = geometry.adjustRectToContainer( - clip.copy().expand(movement), + originalClip.copy().expand(movement), imageInfo.rect, "resize", { targetRatio, resizeNoFlip: true, ratioCorrectBasedOn: "width" }, @@ -167,6 +167,7 @@ interface PointerState { x: number; y: number; pointerId: number; + originalClip: Rect; } const imageCropperHandlerSize = 15; @@ -220,7 +221,7 @@ export function ImageCropper(props: ImageCropperProps) { const actOnMovement = ( e: PointerEvent, - change: (movement: Movement) => void, + change: (movement: Movement, originalClip: Rect) => void, ) => { if ( imageElement == null || @@ -230,17 +231,14 @@ export function ImageCropper(props: ImageCropperProps) { return; } - const { x, y } = pointerStateRef.current; + const { x, y, originalClip } = pointerStateRef.current; const movement = { x: e.clientX - x, y: e.clientY - y, }; - pointerStateRef.current.x = e.clientX; - pointerStateRef.current.y = e.clientY; - - change(convertMovementFromElement(movement, imageElement)); + change(convertMovementFromElement(movement, imageElement), originalClip); }; const onPointerDown = (e: PointerEvent) => { @@ -252,6 +250,7 @@ export function ImageCropper(props: ImageCropperProps) { x: e.clientX, y: e.clientY, pointerId: e.pointerId, + originalClip: clip, }; }; -- cgit v1.2.3 From 502dd817c79018c84b0a958dd4b2e24781e68ae1 Mon Sep 17 00:00:00 2001 From: crupest Date: Mon, 28 Aug 2023 23:36:38 +0800 Subject: ... --- FrontEnd/src/components/ImageCropper.css | 3 +- FrontEnd/src/components/Spinner.tsx | 52 +++++++++++++++++++------------- 2 files changed, 32 insertions(+), 23 deletions(-) (limited to 'FrontEnd/src/components') diff --git a/FrontEnd/src/components/ImageCropper.css b/FrontEnd/src/components/ImageCropper.css index 9631cf1d..03d2038f 100644 --- a/FrontEnd/src/components/ImageCropper.css +++ b/FrontEnd/src/components/ImageCropper.css @@ -1,12 +1,11 @@ .cru-image-cropper-container { position: relative; box-sizing: border-box; + display: flex; user-select: none; } .cru-image-cropper-container img { - left: 0; - top: 0; width: 100%; height: 100%; } diff --git a/FrontEnd/src/components/Spinner.tsx b/FrontEnd/src/components/Spinner.tsx index ec0c2c35..2752a519 100644 --- a/FrontEnd/src/components/Spinner.tsx +++ b/FrontEnd/src/components/Spinner.tsx @@ -1,36 +1,46 @@ -import { CSSProperties } from "react"; -import classnames from "classnames"; - -import { ThemeColor } from "./common"; +import { CSSProperties, ComponentPropsWithoutRef } from "react"; +import classNames from "classnames"; import "./Spinner.css"; -export interface SpinnerProps { +const sizeMap: Record = { + 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?: "sm" | "md" | "lg" | number | string; - color?: ThemeColor; className?: string; style?: CSSProperties; } export default function Spinner(props: SpinnerProps) { - const { size, color, className, style } = props; - const calculatedSize = - size === "sm" - ? "18px" - : size === "md" - ? "30px" - : size === "lg" - ? "42px" - : typeof size === "number" - ? size - : size == null - ? "20px" - : size; + const { size, className, style, ...otherProps } = props; + const calculatedSize = calculateSize(size); return ( ); } -- cgit v1.2.3 From b05860b6d2ea17db29a338659def49dc31082346 Mon Sep 17 00:00:00 2001 From: crupest Date: Tue, 29 Aug 2023 01:30:30 +0800 Subject: Refactor dialog module. --- FrontEnd/src/components/dialog/ConfirmDialog.tsx | 14 +-- FrontEnd/src/components/dialog/Dialog.tsx | 18 +-- FrontEnd/src/components/dialog/DialogProvider.tsx | 95 +++++++++++++++ FrontEnd/src/components/dialog/OperationDialog.tsx | 45 +++---- FrontEnd/src/components/dialog/index.ts | 65 ----------- FrontEnd/src/components/dialog/index.tsx | 12 ++ FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx | 32 +++-- .../src/pages/setting/ChangeNicknameDialog.tsx | 11 +- .../src/pages/setting/ChangePasswordDialog.tsx | 11 +- FrontEnd/src/pages/setting/index.tsx | 130 ++++++++++----------- FrontEnd/src/pages/timeline/MarkdownPostEdit.tsx | 33 +++--- FrontEnd/src/pages/timeline/TimelineCard.tsx | 28 +++-- .../src/pages/timeline/TimelineDeleteDialog.tsx | 4 - .../src/pages/timeline/TimelinePostCreateView.tsx | 2 +- FrontEnd/src/pages/timeline/TimelinePostView.tsx | 48 ++++---- .../timeline/TimelinePropertyChangeDialog.tsx | 4 - 16 files changed, 285 insertions(+), 267 deletions(-) create mode 100644 FrontEnd/src/components/dialog/DialogProvider.tsx delete mode 100644 FrontEnd/src/components/dialog/index.ts create mode 100644 FrontEnd/src/components/dialog/index.tsx (limited to 'FrontEnd/src/components') diff --git a/FrontEnd/src/components/dialog/ConfirmDialog.tsx b/FrontEnd/src/components/dialog/ConfirmDialog.tsx index 1d997305..a7b3917f 100644 --- a/FrontEnd/src/components/dialog/ConfirmDialog.tsx +++ b/FrontEnd/src/components/dialog/ConfirmDialog.tsx @@ -1,18 +1,16 @@ import { useC, Text, ThemeColor } from "../common"; + import Dialog from "./Dialog"; import DialogContainer from "./DialogContainer"; +import { useCloseDialog } from "./DialogProvider"; export default function ConfirmDialog({ - open, - onClose, onConfirm, title, body, color, bodyColor, }: { - open: boolean; - onClose: () => void; onConfirm: () => void; title: Text; body: Text; @@ -21,8 +19,10 @@ export default function ConfirmDialog({ }) { const c = useC(); + const closeDialog = useCloseDialog(); + return ( - + { onConfirm(); - onClose(); + closeDialog(); }, }, }, diff --git a/FrontEnd/src/components/dialog/Dialog.tsx b/FrontEnd/src/components/dialog/Dialog.tsx index 2ff7bea8..b1d66704 100644 --- a/FrontEnd/src/components/dialog/Dialog.tsx +++ b/FrontEnd/src/components/dialog/Dialog.tsx @@ -5,6 +5,8 @@ import classNames from "classnames"; import { ThemeColor } from "../common"; +import { useCloseDialog } from "./DialogProvider"; + import "./Dialog.css"; const optionalPortalElement = document.getElementById("portal"); @@ -14,22 +16,20 @@ if (optionalPortalElement == null) { const portalElement = optionalPortalElement; interface DialogProps { - open: boolean; - onClose: () => void; color?: ThemeColor; children?: ReactNode; disableCloseOnClickOnOverlay?: boolean; } export default function Dialog({ - open, - onClose, color, children, disableCloseOnClickOnOverlay, }: DialogProps) { color = color ?? "primary"; + const closeDialog = useCloseDialog(); + const nodeRef = useRef(null); return ReactDOM.createPortal( @@ -37,7 +37,7 @@ export default function Dialog({ nodeRef={nodeRef} mountOnEnter unmountOnExit - in={open} + in timeout={300} classNames="cru-dialog" > @@ -47,13 +47,7 @@ export default function Dialog({ >
{ - onClose(); - } - } + onClick={disableCloseOnClickOnOverlay ? undefined : closeDialog} />
{children}
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 = { + [K in D]: ReactNode; +}; + +interface DialogController { + 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( + dialogs: DialogMap, + options?: { + initDialog?: D | null; + onClose?: { + [K in D]?: () => void; + }; + }, +): { + controller: DialogController; + switchDialog: (newDialog: D | null) => void; + forceSwitchDialog: (newDialog: D | null) => void; + createDialogSwitch: (newDialog: D | null) => () => void; +} { + const [canSwitchDialog, setCanSwitchDialog] = useState(true); + const [dialog, setDialog] = useState(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 = { + 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 | null>( + null, +); + +export function useDialogController(): DialogController { + 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({ + controller, +}: { + controller: DialogController; +}) { + return ( + + {controller.currentDialogReactNode} + + ); +} diff --git a/FrontEnd/src/components/dialog/OperationDialog.tsx b/FrontEnd/src/components/dialog/OperationDialog.tsx index 96766825..902d60c6 100644 --- a/FrontEnd/src/components/dialog/OperationDialog.tsx +++ b/FrontEnd/src/components/dialog/OperationDialog.tsx @@ -11,6 +11,7 @@ import { import { ButtonRow } from "../button"; import Dialog from "./Dialog"; import DialogContainer from "./DialogContainer"; +import { useDialogController } from "./DialogProvider"; import "./OperationDialog.css"; @@ -35,9 +36,6 @@ function OperationDialogPrompt(props: OperationDialogPromptProps) { } export interface OperationDialogProps { - open: boolean; - onClose: () => void; - color?: ThemeColor; inputColor?: ThemeColor; title: Text; @@ -56,8 +54,6 @@ export interface OperationDialogProps { function OperationDialog(props: OperationDialogProps) { const { - open, - onClose, color, inputColor, title, @@ -96,6 +92,8 @@ function OperationDialog(props: OperationDialogProps) { data: unknown; }; + const dialogController = useDialogController(); + const [step, setStep] = useState({ type: "input" }); const { inputGroupProps, hasErrorAndDirty, setAllDisabled, confirm } = @@ -105,7 +103,7 @@ function OperationDialog(props: OperationDialogProps) { function close() { if (step.type !== "process") { - onClose(); + dialogController.closeDialog(); if (step.type === "success" && onSuccessAndClose) { onSuccessAndClose?.(step.data); } @@ -118,21 +116,26 @@ function OperationDialog(props: OperationDialogProps) { 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, - }); - }, - ); + onProcess(result.values) + .then( + (d) => { + setStep({ + type: "success", + data: d, + }); + }, + (e: unknown) => { + setStep({ + type: "failure", + data: e, + }); + }, + ) + .finally(() => { + dialogController.setCanSwitchDialog(true); + }); } } @@ -214,7 +217,7 @@ function OperationDialog(props: OperationDialogProps) { } return ( - + {body} diff --git a/FrontEnd/src/components/dialog/index.ts b/FrontEnd/src/components/dialog/index.ts deleted file mode 100644 index 17db8fd0..00000000 --- a/FrontEnd/src/components/dialog/index.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { useState } from "react"; - -export { default as Dialog } from "./Dialog"; -export { default as FullPageDialog } from "./FullPageDialog"; -export { default as OperationDialog } from "./OperationDialog"; -export { default as ConfirmDialog } from "./ConfirmDialog"; -export { default as DialogContainer } from "./DialogContainer"; - -type DialogMap = { - [K in D]: V; -}; - -type DialogKeyMap = DialogMap; - -type DialogPropsMap = DialogMap< - D, - { key: number | string; open: boolean; onClose: () => void } ->; - -export function useDialog( - dialogs: D[], - options?: { - initDialog?: D | null; - onClose?: { - [K in D]?: () => void; - }; - }, -): { - dialog: D | null; - switchDialog: (newDialog: D | null) => void; - dialogPropsMap: DialogPropsMap; - createDialogSwitch: (newDialog: D | null) => () => void; -} { - const [dialog, setDialog] = useState(options?.initDialog ?? null); - - const [dialogKeys, setDialogKeys] = useState>( - () => Object.fromEntries(dialogs.map((d) => [d, 0])) as DialogKeyMap, - ); - - const switchDialog = (newDialog: D | null) => { - if (dialog !== null) { - setDialogKeys({ ...dialogKeys, [dialog]: dialogKeys[dialog] + 1 }); - } - setDialog(newDialog); - }; - - return { - dialog, - switchDialog, - dialogPropsMap: Object.fromEntries( - dialogs.map((d) => [ - d, - { - key: `${d}-${dialogKeys[d]}`, - open: dialog === d, - onClose: () => { - switchDialog(null); - options?.onClose?.[d]?.(); - }, - }, - ]), - ) as DialogPropsMap, - createDialogSwitch: (newDialog: D | null) => () => switchDialog(newDialog), - }; -} 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/pages/setting/ChangeAvatarDialog.tsx b/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx index 2fcfef2c..96ae971b 100644 --- a/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx +++ b/FrontEnd/src/pages/setting/ChangeAvatarDialog.tsx @@ -9,23 +9,21 @@ import { getHttpUserClient } from "~src/http/user"; import { ImageCropper, useImageCrop } from "~src/components/ImageCropper"; import BlobImage from "~src/components/BlobImage"; import { ButtonRowV2 } from "~src/components/button"; -import { Dialog, DialogContainer } from "~src/components/dialog"; +import { + Dialog, + DialogContainer, + useDialogController, +} from "~src/components/dialog"; import "./ChangeAvatarDialog.css"; -interface ChangeAvatarDialogProps { - open: boolean; - onClose: () => void; -} - -export default function ChangeAvatarDialog({ - open, - onClose, -}: ChangeAvatarDialogProps) { +export default function ChangeAvatarDialog() { const c = useC(); const user = useUser(); + const controller = useDialogController(); + type State = | "select" | "crop" @@ -49,11 +47,7 @@ export default function ChangeAvatarDialog({ "settings.dialogChangeAvatar.prompt.select", ); - const close = (): void => { - if (state !== "uploading") { - onClose(); - } - }; + const close = controller.closeDialog; const onSelectFile = (e: ChangeEvent): void => { const files = e.target.files; @@ -96,6 +90,7 @@ export default function ChangeAvatarDialog({ } setState("uploading"); + controller.setCanSwitchDialog(false); getHttpUserClient() .putAvatar(user.username, resultBlob) .then( @@ -106,7 +101,10 @@ export default function ChangeAvatarDialog({ setState("error"); setMessage("operationDialog.error"); }, - ); + ) + .finally(() => { + controller.setCanSwitchDialog(true); + }); }; const cancelButton = { @@ -181,7 +179,7 @@ export default function ChangeAvatarDialog({ }; return ( - + void; -} - -export default function ChangeNicknameDialog(props: ChangeNicknameDialogProps) { - const { open, onClose } = props; - +export default function ChangeNicknameDialog() { const user = useUserLoggedIn(); return ( ); } diff --git a/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx b/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx index 946b9fbe..c3111ac8 100644 --- a/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx +++ b/FrontEnd/src/pages/setting/ChangePasswordDialog.tsx @@ -5,22 +5,13 @@ import { userService } from "~src/services/user"; import { OperationDialog } from "~src/components/dialog"; -interface ChangePasswordDialogProps { - open: boolean; - onClose: () => void; -} - -export function ChangePasswordDialog(props: ChangePasswordDialogProps) { - const { open, onClose } = props; - +export function ChangePasswordDialog() { const navigate = useNavigate(); const [redirect, setRedirect] = useState(false); return ( (); - const [dialogOpen, setDialogOpen] = useState(false); + const { controller, createDialogSwitch } = useDialog({ + confirm: ( + { + if (user == null) throw new Error(); + void getHttpUserClient() + .renewRegisterCode(user.username) + .then(() => { + setRegisterCode(undefined); + }); + }} + /> + ), + }); useEffect(() => { setRegisterCode(undefined); @@ -157,49 +176,34 @@ function RegisterCodeSettingItem() { }, [user, registerCode]); return ( - <> - setDialogOpen(true)} - > - {registerCode === undefined ? ( - - ) : registerCode === null ? ( - Noop - ) : ( - { - void navigator.clipboard.writeText(registerCode).then(() => { - pushAlert({ - type: "create", - message: "settings.myRegisterCodeCopied", - }); + + {registerCode === undefined ? ( + + ) : registerCode === null ? ( + Noop + ) : ( + { + void navigator.clipboard.writeText(registerCode).then(() => { + pushAlert({ + type: "create", + message: "settings.myRegisterCodeCopied", }); - event.stopPropagation(); - }} - > - {registerCode} - - )} - - setDialogOpen(false)} - open={dialogOpen} - onConfirm={() => { - if (user == null) throw new Error(); - void getHttpUserClient() - .renewRegisterCode(user.username) - .then(() => { - setRegisterCode(undefined); }); - }} - />{" "} - + event.stopPropagation(); + }} + > + {registerCode} + + )} + + ); } @@ -240,12 +244,22 @@ export default function SettingPage() { const user = useUser(); const navigate = useNavigate(); - const { dialogPropsMap, createDialogSwitch } = useDialog([ - "change-password", - "change-avatar", - "change-nickname", - "logout", - ]); + const { controller, createDialogSwitch } = useDialog({ + "change-password": , + "change-avatar": , + "change-nickname": , + logout: ( + { + void userService.logout().then(() => { + navigate("/"); + }); + }} + /> + ), + }); return ( @@ -275,23 +289,7 @@ export default function SettingPage() { - - {user && ( - <> - { - void userService.logout().then(() => { - navigate("/"); - }); - }} - {...dialogPropsMap["logout"]} - /> - - - - )} + ); } diff --git a/FrontEnd/src/pages/timeline/MarkdownPostEdit.tsx b/FrontEnd/src/pages/timeline/MarkdownPostEdit.tsx index 43e81d67..fc7b882f 100644 --- a/FrontEnd/src/pages/timeline/MarkdownPostEdit.tsx +++ b/FrontEnd/src/pages/timeline/MarkdownPostEdit.tsx @@ -2,7 +2,10 @@ import * as React from "react"; import classnames from "classnames"; import { useTranslation } from "react-i18next"; -import { getHttpTimelineClient, HttpTimelinePostInfo } from "~src/http/timeline"; +import { + getHttpTimelineClient, + HttpTimelinePostInfo, +} from "~src/http/timeline"; import TimelinePostBuilder from "~src/services/TimelinePostBuilder"; @@ -13,6 +16,7 @@ import Spinner from "~src/components/Spinner"; import IconButton from "~src/components/button/IconButton"; import "./MarkdownPostEdit.css"; +import { DialogProvider, useDialog } from "~src/components/dialog"; export interface MarkdownPostEditProps { owner: string; @@ -39,12 +43,19 @@ const MarkdownPostEdit: React.FC = ({ const [process, setProcess] = React.useState(false); - const [showLeaveConfirmDialog, setShowLeaveConfirmDialog] = - React.useState(false); + const { controller, switchDialog } = useDialog({ + "leave-confirm": ( + + ), + }); const [text, _setText] = React.useState(""); const [images, _setImages] = React.useState<{ file: File; url: string }[]>( - [] + [], ); const [previewHtml, _setPreviewHtml] = React.useState(""); @@ -92,7 +103,7 @@ const MarkdownPostEdit: React.FC = ({ timelineName, { dataList, - } + }, ); onPosted(post); onClose(); @@ -123,7 +134,7 @@ const MarkdownPostEdit: React.FC = ({ if (canLeave) { onClose(); } else { - setShowLeaveConfirmDialog(true); + switchDialog("leave-confirm"); } }} /> @@ -167,7 +178,7 @@ const MarkdownPostEdit: React.FC = ({ color="danger" className={classnames( "timeline-markdown-post-edit-image-delete-button", - process && "d-none" + process && "d-none", )} onClick={() => { getBuilder().deleteImage(index); @@ -201,13 +212,7 @@ const MarkdownPostEdit: React.FC = ({ }, ]} /> - setShowLeaveConfirmDialog(false)} - onConfirm={onClose} - open={showLeaveConfirmDialog} - title="timeline.dropDraft" - body="timeline.confirmLeave" - /> + ); }; diff --git a/FrontEnd/src/pages/timeline/TimelineCard.tsx b/FrontEnd/src/pages/timeline/TimelineCard.tsx index f17a3ce9..133f1ef4 100644 --- a/FrontEnd/src/pages/timeline/TimelineCard.tsx +++ b/FrontEnd/src/pages/timeline/TimelineCard.tsx @@ -8,7 +8,7 @@ import { HttpTimelineInfo } from "~src/http/timeline"; import { getHttpBookmarkClient } from "~src/http/bookmark"; import { useMobile } from "~src/components/hooks"; -import { Dialog, useDialog } from "~src/components/dialog"; +import { Dialog, DialogProvider, useDialog } from "~src/components/dialog"; import UserAvatar from "~src/components/user/UserAvatar"; import PopupMenu from "~src/components/menu/PopupMenu"; import FullPageDialog from "~src/components/dialog/FullPageDialog"; @@ -40,11 +40,17 @@ export default function TimelineCard(props: TimelinePageCardProps) { const isMobile = useMobile(); - const { createDialogSwitch, dialogPropsMap } = useDialog([ - "member", - "property", - "delete", - ]); + const { controller, createDialogSwitch } = useDialog({ + member: ( + + + + ), + property: ( + + ), + delete: , + }); const content = (
@@ -144,15 +150,7 @@ export default function TimelineCard(props: TimelinePageCardProps) { ) : (
{content}
)} - - - - - + ); } diff --git a/FrontEnd/src/pages/timeline/TimelineDeleteDialog.tsx b/FrontEnd/src/pages/timeline/TimelineDeleteDialog.tsx index a7209e75..630ce4ca 100644 --- a/FrontEnd/src/pages/timeline/TimelineDeleteDialog.tsx +++ b/FrontEnd/src/pages/timeline/TimelineDeleteDialog.tsx @@ -8,8 +8,6 @@ import OperationDialog from "~src/components/dialog/OperationDialog"; interface TimelineDeleteDialog { timeline: HttpTimelineInfo; - open: boolean; - onClose: () => void; } const TimelineDeleteDialog: React.FC = (props) => { @@ -19,8 +17,6 @@ const TimelineDeleteDialog: React.FC = (props) => { return ( {file != null && !error && ( onSelect(file)} onError={() => { diff --git a/FrontEnd/src/pages/timeline/TimelinePostView.tsx b/FrontEnd/src/pages/timeline/TimelinePostView.tsx index 6b87ef2a..5de09b28 100644 --- a/FrontEnd/src/pages/timeline/TimelinePostView.tsx +++ b/FrontEnd/src/pages/timeline/TimelinePostView.tsx @@ -9,7 +9,7 @@ import { pushAlert } from "~src/services/alert"; import { useClickOutside } from "~src/components/hooks"; import UserAvatar from "~src/components/user/UserAvatar"; -import { useDialog } from "~src/components/dialog"; +import { DialogProvider, useDialog } from "~src/components/dialog"; import FlatButton from "~src/components/button/FlatButton"; import ConfirmDialog from "~src/components/dialog/ConfirmDialog"; import TimelinePostContentView from "./TimelinePostContentView"; @@ -33,13 +33,33 @@ export default function TimelinePostView(props: TimelinePostViewProps) { const [operationMaskVisible, setOperationMaskVisible] = useState(false); - const { switchDialog, dialogPropsMap } = useDialog(["delete"], { - onClose: { - delete: () => { - setOperationMaskVisible(false); + const { controller, switchDialog } = useDialog( + { + delete: ( + { + void getHttpTimelineClient() + .deletePost(post.timelineOwnerV2, post.timelineNameV2, post.id) + .then(onDeleted, () => { + pushAlert({ + type: "danger", + message: "timeline.deletePostFailed", + }); + }); + }} + /> + ), + }, + { + onClose: { + delete: () => { + setOperationMaskVisible(false); + }, }, }, - }); + ); const [maskElement, setMaskElement] = useState(null); useClickOutside(maskElement, () => setOperationMaskVisible(false)); @@ -98,21 +118,7 @@ export default function TimelinePostView(props: TimelinePostViewProps) {
) : null} - { - void getHttpTimelineClient() - .deletePost(post.timelineOwnerV2, post.timelineNameV2, post.id) - .then(onDeleted, () => { - pushAlert({ - type: "danger", - message: "timeline.deletePostFailed", - }); - }); - }} - {...dialogPropsMap.delete} - /> + ); } diff --git a/FrontEnd/src/pages/timeline/TimelinePropertyChangeDialog.tsx b/FrontEnd/src/pages/timeline/TimelinePropertyChangeDialog.tsx index afd83a5f..ee5388cb 100644 --- a/FrontEnd/src/pages/timeline/TimelinePropertyChangeDialog.tsx +++ b/FrontEnd/src/pages/timeline/TimelinePropertyChangeDialog.tsx @@ -11,8 +11,6 @@ import { import OperationDialog from "~src/components/dialog/OperationDialog"; export interface TimelinePropertyChangeDialogProps { - open: boolean; - onClose: () => void; timeline: HttpTimelineInfo; onChange: () => void; } @@ -63,8 +61,6 @@ const TimelinePropertyChangeDialog: React.FC< }, }, }} - open={props.open} - onClose={props.onClose} onProcess={({ title, visibility, description }) => { const req: HttpTimelinePatchRequest = {}; if (title !== timeline.title) { -- cgit v1.2.3 From d65dcebc3ed64c96c70f0ee7f228b4dfe79b28a1 Mon Sep 17 00:00:00 2001 From: crupest Date: Tue, 29 Aug 2023 20:41:34 +0800 Subject: ... --- FrontEnd/src/components/common.ts | 2 + FrontEnd/src/components/dialog/Dialog.css | 4 +- FrontEnd/src/components/dialog/Dialog.tsx | 39 ++--- FrontEnd/src/components/theme-color.css | 173 --------------------- FrontEnd/src/components/theme.css | 100 ++++++------ FrontEnd/src/index.css | 3 - .../src/pages/setting/ChangeNicknameDialog.tsx | 2 +- FrontEnd/src/pages/setting/index.tsx | 4 +- FrontEnd/src/utilities/index.ts | 10 ++ 9 files changed, 88 insertions(+), 249 deletions(-) delete mode 100644 FrontEnd/src/components/theme-color.css create mode 100644 FrontEnd/src/utilities/index.ts (limited to 'FrontEnd/src/components') diff --git a/FrontEnd/src/components/common.ts b/FrontEnd/src/components/common.ts index 171ebc48..835a8b4a 100644 --- a/FrontEnd/src/components/common.ts +++ b/FrontEnd/src/components/common.ts @@ -1,3 +1,5 @@ +import "./index.css"; + export type { Text, I18nText } from "~src/common"; export { UiLogicError, c, convertI18nText, useC } from "~src/common"; diff --git a/FrontEnd/src/components/dialog/Dialog.css b/FrontEnd/src/components/dialog/Dialog.css index e4c61440..f25309ae 100644 --- a/FrontEnd/src/components/dialog/Dialog.css +++ b/FrontEnd/src/components/dialog/Dialog.css @@ -17,7 +17,7 @@ right: 0; top: 0; bottom: 0; - background-color: var(--cru-surface-dim-color); + background-color: var(--cru-dialog-overlay-color); opacity: 0.8; } @@ -27,7 +27,7 @@ margin: 2em auto; - border: var(--cru-key-container-color) 1px solid; + border: var(--cru-dialog-container-background-color) 1px solid; border-radius: 5px; padding: 1.5em; background-color: var(--cru-surface-color); diff --git a/FrontEnd/src/components/dialog/Dialog.tsx b/FrontEnd/src/components/dialog/Dialog.tsx index b1d66704..bdba9198 100644 --- a/FrontEnd/src/components/dialog/Dialog.tsx +++ b/FrontEnd/src/components/dialog/Dialog.tsx @@ -1,6 +1,5 @@ import { ReactNode, useRef } from "react"; import ReactDOM from "react-dom"; -import { CSSTransition } from "react-transition-group"; import classNames from "classnames"; import { ThemeColor } from "../common"; @@ -16,42 +15,34 @@ if (optionalPortalElement == null) { const portalElement = optionalPortalElement; interface DialogProps { - color?: ThemeColor; children?: ReactNode; disableCloseOnClickOnOverlay?: boolean; } export default function Dialog({ - color, children, disableCloseOnClickOnOverlay, }: DialogProps) { - color = color ?? "primary"; - const closeDialog = useCloseDialog(); - const nodeRef = useRef(null); + const lastPointerDownIdRef = useRef(null); return ReactDOM.createPortal( - +
-
-
{children}
-
- , + className="cru-dialog-background" + onPointerDown={(e) => { + lastPointerDownIdRef.current = e.pointerId; + }} + onPointerUp={(e) => { + if (lastPointerDownIdRef.current === e.pointerId) { + if (!disableCloseOnClickOnOverlay) closeDialog(); + } + lastPointerDownIdRef.current = null; + }} + /> +
{children}
+
, portalElement, ); } diff --git a/FrontEnd/src/components/theme-color.css b/FrontEnd/src/components/theme-color.css deleted file mode 100644 index 24a7e267..00000000 --- a/FrontEnd/src/components/theme-color.css +++ /dev/null @@ -1,173 +0,0 @@ -/* Generated by theme-generator.ts */ - -:root { - --cru-primary-color: hsl(210 100% 40%); - --cru-primary-1-color: hsl(210 100% 37%); - --cru-primary-2-color: hsl(210 100% 34%); - --cru-primary-on-color: hsl(210 100% 100%); - --cru-primary-container-color: hsl(210 100% 90%); - --cru-primary-container-1-color: hsl(210 100% 80%); - --cru-primary-container-2-color: hsl(210 100% 70%); - --cru-primary-on-container-color: hsl(210 100% 10%); - --cru-secondary-color: hsl(40 100% 40%); - --cru-secondary-1-color: hsl(40 100% 37%); - --cru-secondary-2-color: hsl(40 100% 34%); - --cru-secondary-on-color: hsl(40 100% 100%); - --cru-secondary-container-color: hsl(40 100% 90%); - --cru-secondary-container-1-color: hsl(40 100% 80%); - --cru-secondary-container-2-color: hsl(40 100% 70%); - --cru-secondary-on-container-color: hsl(40 100% 10%); - --cru-tertiary-color: hsl(160 100% 40%); - --cru-tertiary-1-color: hsl(160 100% 37%); - --cru-tertiary-2-color: hsl(160 100% 34%); - --cru-tertiary-on-color: hsl(160 100% 100%); - --cru-tertiary-container-color: hsl(160 100% 90%); - --cru-tertiary-container-1-color: hsl(160 100% 80%); - --cru-tertiary-container-2-color: hsl(160 100% 70%); - --cru-tertiary-on-container-color: hsl(160 100% 10%); - --cru-danger-color: hsl(0 100% 40%); - --cru-danger-1-color: hsl(0 100% 37%); - --cru-danger-2-color: hsl(0 100% 34%); - --cru-danger-on-color: hsl(0 100% 100%); - --cru-danger-container-color: hsl(0 100% 90%); - --cru-danger-container-1-color: hsl(0 100% 80%); - --cru-danger-container-2-color: hsl(0 100% 70%); - --cru-danger-on-container-color: hsl(0 100% 10%); - --cru-success-color: hsl(120 60% 40%); - --cru-success-1-color: hsl(120 60% 37%); - --cru-success-2-color: hsl(120 60% 34%); - --cru-success-on-color: hsl(120 60% 100%); - --cru-success-container-color: hsl(120 60% 90%); - --cru-success-container-1-color: hsl(120 60% 80%); - --cru-success-container-2-color: hsl(120 60% 70%); - --cru-success-on-container-color: hsl(120 60% 10%); - --cru-surface-dim-color: hsl(0 0% 87%); - --cru-surface-color: hsl(0 0% 98%); - --cru-surface-1-color: hsl(0 0% 90%); - --cru-surface-2-color: hsl(0 0% 82%); - --cru-surface-bright-color: hsl(0 0% 98%); - --cru-surface-container-lowest-color: hsl(0 0% 100%); - --cru-surface-container-low-color: hsl(0 0% 96%); - --cru-surface-container-color: hsl(0 0% 94%); - --cru-surface-container-high-color: hsl(0 0% 92%); - --cru-surface-container-highest-color: hsl(0 0% 90%); - --cru-surface-on-color: hsl(0 0% 10%); - --cru-surface-on-variant-color: hsl(0 0% 30%); - --cru-surface-outline-color: hsl(0 0% 50%); - --cru-surface-outline-variant-color: hsl(0 0% 80%); -} - -@media (prefers-color-scheme: dark) { - :root { - --cru-primary-color: hsl(210 100% 80%); - --cru-primary-1-color: hsl(210 100% 75%); - --cru-primary-2-color: hsl(210 100% 68%); - --cru-primary-on-color: hsl(210 100% 20%); - --cru-primary-container-color: hsl(210 100% 30%); - --cru-primary-container-1-color: hsl(210 100% 25%); - --cru-primary-container-2-color: hsl(210 100% 20%); - --cru-primary-on-container-color: hsl(210 100% 90%); - --cru-secondary-color: hsl(40 100% 80%); - --cru-secondary-1-color: hsl(40 100% 75%); - --cru-secondary-2-color: hsl(40 100% 68%); - --cru-secondary-on-color: hsl(40 100% 20%); - --cru-secondary-container-color: hsl(40 100% 30%); - --cru-secondary-container-1-color: hsl(40 100% 25%); - --cru-secondary-container-2-color: hsl(40 100% 20%); - --cru-secondary-on-container-color: hsl(40 100% 90%); - --cru-tertiary-color: hsl(160 100% 80%); - --cru-tertiary-1-color: hsl(160 100% 75%); - --cru-tertiary-2-color: hsl(160 100% 68%); - --cru-tertiary-on-color: hsl(160 100% 20%); - --cru-tertiary-container-color: hsl(160 100% 30%); - --cru-tertiary-container-1-color: hsl(160 100% 25%); - --cru-tertiary-container-2-color: hsl(160 100% 20%); - --cru-tertiary-on-container-color: hsl(160 100% 90%); - --cru-danger-color: hsl(0 100% 80%); - --cru-danger-1-color: hsl(0 100% 75%); - --cru-danger-2-color: hsl(0 100% 68%); - --cru-danger-on-color: hsl(0 100% 20%); - --cru-danger-container-color: hsl(0 100% 30%); - --cru-danger-container-1-color: hsl(0 100% 25%); - --cru-danger-container-2-color: hsl(0 100% 20%); - --cru-danger-on-container-color: hsl(0 100% 90%); - --cru-success-color: hsl(120 60% 80%); - --cru-success-1-color: hsl(120 60% 75%); - --cru-success-2-color: hsl(120 60% 68%); - --cru-success-on-color: hsl(120 60% 20%); - --cru-success-container-color: hsl(120 60% 30%); - --cru-success-container-1-color: hsl(120 60% 25%); - --cru-success-container-2-color: hsl(120 60% 20%); - --cru-success-on-container-color: hsl(120 60% 90%); - --cru-surface-dim-color: hsl(0 0% 6%); - --cru-surface-color: hsl(0 0% 6%); - --cru-surface-1-color: hsl(0 0% 25%); - --cru-surface-2-color: hsl(0 0% 40%); - --cru-surface-bright-color: hsl(0 0% 24%); - --cru-surface-container-lowest-color: hsl(0 0% 4%); - --cru-surface-container-low-color: hsl(0 0% 10%); - --cru-surface-container-color: hsl(0 0% 12%); - --cru-surface-container-high-color: hsl(0 0% 17%); - --cru-surface-container-highest-color: hsl(0 0% 22%); - --cru-surface-on-color: hsl(0 0% 90%); - --cru-surface-on-variant-color: hsl(0 0% 80%); - --cru-surface-outline-color: hsl(0 0% 60%); - --cru-surface-outline-variant-color: hsl(0 0% 30%); - } -} - -.cru-primary { - --cru-key-color: var(--cru-primary-color); - --cru-key-1-color: var(--cru-primary-1-color); - --cru-key-2-color: var(--cru-primary-2-color); - --cru-key-on-color: var(--cru-primary-on-color); - --cru-key-container-color: var(--cru-primary-container-color); - --cru-key-container-1-color: var(--cru-primary-container-1-color); - --cru-key-container-2-color: var(--cru-primary-container-2-color); - --cru-key-on-container-color: var(--cru-primary-on-container-color); -} - -.cru-secondary { - --cru-key-color: var(--cru-secondary-color); - --cru-key-1-color: var(--cru-secondary-1-color); - --cru-key-2-color: var(--cru-secondary-2-color); - --cru-key-on-color: var(--cru-secondary-on-color); - --cru-key-container-color: var(--cru-secondary-container-color); - --cru-key-container-1-color: var(--cru-secondary-container-1-color); - --cru-key-container-2-color: var(--cru-secondary-container-2-color); - --cru-key-on-container-color: var(--cru-secondary-on-container-color); -} - -.cru-tertiary { - --cru-key-color: var(--cru-tertiary-color); - --cru-key-1-color: var(--cru-tertiary-1-color); - --cru-key-2-color: var(--cru-tertiary-2-color); - --cru-key-on-color: var(--cru-tertiary-on-color); - --cru-key-container-color: var(--cru-tertiary-container-color); - --cru-key-container-1-color: var(--cru-tertiary-container-1-color); - --cru-key-container-2-color: var(--cru-tertiary-container-2-color); - --cru-key-on-container-color: var(--cru-tertiary-on-container-color); -} - -.cru-danger { - --cru-key-color: var(--cru-danger-color); - --cru-key-1-color: var(--cru-danger-1-color); - --cru-key-2-color: var(--cru-danger-2-color); - --cru-key-on-color: var(--cru-danger-on-color); - --cru-key-container-color: var(--cru-danger-container-color); - --cru-key-container-1-color: var(--cru-danger-container-1-color); - --cru-key-container-2-color: var(--cru-danger-container-2-color); - --cru-key-on-container-color: var(--cru-danger-on-container-color); -} - -.cru-success { - --cru-key-color: var(--cru-success-color); - --cru-key-1-color: var(--cru-success-1-color); - --cru-key-2-color: var(--cru-success-2-color); - --cru-key-on-color: var(--cru-success-on-color); - --cru-key-container-color: var(--cru-success-container-color); - --cru-key-container-1-color: var(--cru-success-container-1-color); - --cru-key-container-2-color: var(--cru-success-container-2-color); - --cru-key-on-container-color: var(--cru-success-on-container-color); -} - diff --git a/FrontEnd/src/components/theme.css b/FrontEnd/src/components/theme.css index 6ceb369f..d7e30d1a 100644 --- a/FrontEnd/src/components/theme.css +++ b/FrontEnd/src/components/theme.css @@ -1,5 +1,3 @@ -@import "./theme-color.css"; - :root { --cru-default-font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; --cru-page-padding: 1em 2em; @@ -10,26 +8,26 @@ /* 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-primary-color: hsl(210 100% 50%); + --cru-secondary-color: hsl(30 100% 50%); + --cru-create-color: hsl(120 100% 25%); + --cru-danger-color: hsl(0 100% 50%); } /* common colors */ :root { - --cru-background-color: hsl(0, 0%, 100%); - --cru-container-background-color: hsl(0, 0%, 97%); - --cru-text-primary-color: hsl(0, 0%, 0%); - --cru-text-secondary-color: hsl(0, 0%, 38%); + --cru-background-color: hsl(0 0% 100%); + --cru-container-background-color: hsl(0 0% 97%); + --cru-text-primary-color: hsl(0 0% 0%); + --cru-text-secondary-color: hsl(0 0% 38%); } @media (prefers-color-scheme: dark) { :root { - --cru-background-color: hsl(0, 0%, 0%); - --cru-container-background-color: hsl(0, 0%, 2%); - --cru-text-primary-color: hsl(0, 0%, 100%); - --cru-text-secondary-color: hsl(0, 0%, 85%); + --cru-background-color: hsl(0 0% 0%); + --cru-container-background-color: hsl(0 0% 2%); + --cru-text-primary-color: hsl(0 0% 100%); + --cru-text-secondary-color: hsl(0 0% 85%); } } @@ -37,37 +35,51 @@ --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-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-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-create-hover-color: hsl(120 100% 35%); + --cru-clickable-create-focus-color: hsl(120 100% 35%); + --cru-clickable-create-active-color: hsl(120 100% 35%); --cru-clickable-danger-normal-color: var(--cru-danger-color); - --cru-clickable-danger-hover-color: hsl(0, 100%, 60%); - --cru-clickable-danger-focus-color: hsl(0, 100%, 60%); - --cru-clickable-danger-active-color: hsl(0, 100%, 70%); - --cru-clickable-grayscale-normal-color: hsl(0, 0%, 100%); - --cru-clickable-grayscale-hover-color: hsl(0, 0%, 92%); - --cru-clickable-grayscale-focus-color: hsl(0, 0%, 92%); - --cru-clickable-grayscale-active-color: hsl(0, 0%, 88%); - --cru-clickable-disabled-color: hsl(0, 0%, 50%); + --cru-clickable-danger-hover-color: hsl(0 100% 60%); + --cru-clickable-danger-focus-color: hsl(0 100% 60%); + --cru-clickable-danger-active-color: hsl(0 100% 70%); + --cru-clickable-grayscale-normal-color: hsl(0 0% 100%); + --cru-clickable-grayscale-hover-color: hsl(0 0% 92%); + --cru-clickable-grayscale-focus-color: hsl(0 0% 92%); + --cru-clickable-grayscale-active-color: hsl(0 0% 88%); + --cru-clickable-disabled-color: hsl(0 0% 50%); } @media (prefers-color-scheme: dark) { :root { - --cru-clickable-grayscale-normal-color: hsl(0, 0%, 0%); - --cru-clickable-grayscale-hover-color: hsl(0, 0%, 10%); - --cru-clickable-grayscale-focus-color: hsl(0, 0%, 10%); - --cru-clickable-grayscale-active-color: hsl(0, 0%, 20%); + --cru-clickable-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%); } } @@ -110,19 +122,19 @@ :root { /* push button colors */ --cru-push-button-text-color: #ffffff; - --cru-push-button-disabled-text-color: hsl(0, 0%, 80%); + --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-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 { diff --git a/FrontEnd/src/index.css b/FrontEnd/src/index.css index ee92520b..f779297b 100644 --- a/FrontEnd/src/index.css +++ b/FrontEnd/src/index.css @@ -1,8 +1,5 @@ @import "npm:bootstrap-icons/font/bootstrap-icons.css"; -@import "./views/common/index.css"; - - small { line-height: 1.2; } diff --git a/FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx b/FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx index bd1eaa51..912f554f 100644 --- a/FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx +++ b/FrontEnd/src/pages/setting/ChangeNicknameDialog.tsx @@ -1,7 +1,7 @@ import { getHttpUserClient } from "~src/http/user"; import { useUserLoggedIn } from "~src/services/user"; -import OperationDialog from "~src/components/dialog/OperationDialog"; +import { OperationDialog } from "~src/components/dialog"; export default function ChangeNicknameDialog() { const user = useUserLoggedIn(); diff --git a/FrontEnd/src/pages/setting/index.tsx b/FrontEnd/src/pages/setting/index.tsx index 4d2c28c7..d2333134 100644 --- a/FrontEnd/src/pages/setting/index.tsx +++ b/FrontEnd/src/pages/setting/index.tsx @@ -245,9 +245,9 @@ export default function SettingPage() { const navigate = useNavigate(); const { controller, createDialogSwitch } = useDialog({ - "change-password": , + "change-nickname": , "change-avatar": , - "change-nickname": , + "change-password": , logout: ( { + return new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, milliseconds); + }); +} -- cgit v1.2.3 From 5c624ecb5c7e33039d9f14dbce099e4874efb23b Mon Sep 17 00:00:00 2001 From: crupest Date: Wed, 30 Aug 2023 00:34:47 +0800 Subject: ... --- FrontEnd/src/components/dialog/ConfirmDialog.tsx | 5 ++--- FrontEnd/src/components/dialog/Dialog.css | 24 ++-------------------- FrontEnd/src/components/dialog/Dialog.tsx | 9 +++++++- FrontEnd/src/components/dialog/DialogContainer.css | 3 +-- FrontEnd/src/components/dialog/DialogContainer.tsx | 2 +- FrontEnd/src/components/dialog/OperationDialog.css | 4 ---- FrontEnd/src/components/dialog/OperationDialog.tsx | 2 +- FrontEnd/src/components/theme.css | 16 +++++++++++++++ 8 files changed, 31 insertions(+), 34 deletions(-) (limited to 'FrontEnd/src/components') diff --git a/FrontEnd/src/components/dialog/ConfirmDialog.tsx b/FrontEnd/src/components/dialog/ConfirmDialog.tsx index a7b3917f..97cad452 100644 --- a/FrontEnd/src/components/dialog/ConfirmDialog.tsx +++ b/FrontEnd/src/components/dialog/ConfirmDialog.tsx @@ -9,7 +9,6 @@ export default function ConfirmDialog({ title, body, color, - bodyColor, }: { onConfirm: () => void; title: Text; @@ -22,7 +21,7 @@ export default function ConfirmDialog({ const closeDialog = useCloseDialog(); return ( - + -
{c(body)}
+
{c(body)}
); diff --git a/FrontEnd/src/components/dialog/Dialog.css b/FrontEnd/src/components/dialog/Dialog.css index f25309ae..e4f52a92 100644 --- a/FrontEnd/src/components/dialog/Dialog.css +++ b/FrontEnd/src/components/dialog/Dialog.css @@ -27,34 +27,14 @@ margin: 2em auto; - border: var(--cru-dialog-container-background-color) 1px solid; + border: var(--cru-theme-color) 2px solid; border-radius: 5px; padding: 1.5em; - background-color: var(--cru-surface-color); + background-color: var(--cru-dialog-container-background-color); } @media (min-width: 576px) { .cru-dialog-container { max-width: 800px; } -} - -.cru-dialog-enter .cru-dialog-container { - transform: scale(0, 0); - opacity: 0; - transform-origin: center; -} - -.cru-dialog-enter-active .cru-dialog-container { - transform: scale(1, 1); - opacity: 1; - transition: transform 0.3s, opacity 0.3s; - transform-origin: center; -} - -.cru-dialog-exit-active .cru-dialog-container { - transition: transform 0.3s, opacity 0.3s; - transform: scale(0, 0); - opacity: 0; - transform-origin: center; } \ No newline at end of file diff --git a/FrontEnd/src/components/dialog/Dialog.tsx b/FrontEnd/src/components/dialog/Dialog.tsx index bdba9198..85e8ca46 100644 --- a/FrontEnd/src/components/dialog/Dialog.tsx +++ b/FrontEnd/src/components/dialog/Dialog.tsx @@ -15,11 +15,13 @@ if (optionalPortalElement == null) { const portalElement = optionalPortalElement; interface DialogProps { + color?: ThemeColor; children?: ReactNode; disableCloseOnClickOnOverlay?: boolean; } export default function Dialog({ + color, children, disableCloseOnClickOnOverlay, }: DialogProps) { @@ -28,7 +30,12 @@ export default function Dialog({ const lastPointerDownIdRef = useRef(null); return ReactDOM.createPortal( -
+
{ diff --git a/FrontEnd/src/components/dialog/DialogContainer.css b/FrontEnd/src/components/dialog/DialogContainer.css index fbb18e0d..b3c52511 100644 --- a/FrontEnd/src/components/dialog/DialogContainer.css +++ b/FrontEnd/src/components/dialog/DialogContainer.css @@ -1,11 +1,10 @@ .cru-dialog-container-title { font-size: 1.2em; font-weight: bold; - color: var(--cru-key-color); + color: var(--cru-theme-color); margin-bottom: 0.5em; } - .cru-dialog-container-hr { margin: 1em 0; } diff --git a/FrontEnd/src/components/dialog/DialogContainer.tsx b/FrontEnd/src/components/dialog/DialogContainer.tsx index afee2669..6ee4e134 100644 --- a/FrontEnd/src/components/dialog/DialogContainer.tsx +++ b/FrontEnd/src/components/dialog/DialogContainer.tsx @@ -52,7 +52,7 @@ export default function DialogContainer(props: DialogContainerProps) {
diff --git a/FrontEnd/src/components/dialog/OperationDialog.css b/FrontEnd/src/components/dialog/OperationDialog.css index f4b7237e..28f73c9d 100644 --- a/FrontEnd/src/components/dialog/OperationDialog.css +++ b/FrontEnd/src/components/dialog/OperationDialog.css @@ -1,7 +1,3 @@ -.cru-operation-dialog-prompt { - color: var(--cru-surface-on-color); -} - .cru-operation-dialog-input-group { display: block; margin: 0.5em 0; diff --git a/FrontEnd/src/components/dialog/OperationDialog.tsx b/FrontEnd/src/components/dialog/OperationDialog.tsx index 902d60c6..4b4ceb36 100644 --- a/FrontEnd/src/components/dialog/OperationDialog.tsx +++ b/FrontEnd/src/components/dialog/OperationDialog.tsx @@ -217,7 +217,7 @@ function OperationDialog(props: OperationDialogProps) { } return ( - + {body} diff --git a/FrontEnd/src/components/theme.css b/FrontEnd/src/components/theme.css index d7e30d1a..67340b6f 100644 --- a/FrontEnd/src/components/theme.css +++ b/FrontEnd/src/components/theme.css @@ -14,6 +14,22 @@ --cru-danger-color: hsl(0 100% 50%); } +.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%); -- cgit v1.2.3 From c7e781ffc4b347aa05ce0547a106a01e2fd792f3 Mon Sep 17 00:00:00 2001 From: crupest Date: Wed, 30 Aug 2023 00:49:42 +0800 Subject: ... --- FrontEnd/src/components/Skeleton.css | 10 ++++++++-- FrontEnd/src/components/Skeleton.tsx | 32 +++++++++++--------------------- FrontEnd/src/utilities/index.ts | 17 +++++++++++++++++ 3 files changed, 36 insertions(+), 23 deletions(-) (limited to 'FrontEnd/src/components') diff --git a/FrontEnd/src/components/Skeleton.css b/FrontEnd/src/components/Skeleton.css index a571eead..0f78d3b5 100644 --- a/FrontEnd/src/components/Skeleton.css +++ b/FrontEnd/src/components/Skeleton.css @@ -4,11 +4,17 @@ .cru-skeleton-line { height: 1em; - background-color: hsl(0, 0%, 90%); + background-color: hsl(0 0% 90%); margin: 0.7em 0; border-radius: 0.2em; } -.cru-skeleton-line.last { +@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 index 3b149db9..03f80df5 100644 --- a/FrontEnd/src/components/Skeleton.tsx +++ b/FrontEnd/src/components/Skeleton.tsx @@ -1,32 +1,22 @@ -import * as React from "react"; -import classnames from "classnames"; -import range from "lodash/range"; +import { ComponentPropsWithoutRef } from "react"; +import classNames from "classnames"; + +import { range } from "~src/utilities"; import "./Skeleton.css"; -export interface SkeletonProps { +interface SkeletonProps extends ComponentPropsWithoutRef<"div"> { lineNumber?: number; - className?: string; - style?: React.CSSProperties; } -const Skeleton: React.FC = (props) => { - const { lineNumber: lineNumberProps, className, style } = props; - const lineNumber = lineNumberProps ?? 3; +export default function Skeleton(props: SkeletonProps) { + const { lineNumber, className, ...otherProps } = props; return ( -
- {range(lineNumber).map((i) => ( -
+
+ {range(lineNumber ?? 3).map((i) => ( +
))}
); -}; - -export default Skeleton; +} diff --git a/FrontEnd/src/utilities/index.ts b/FrontEnd/src/utilities/index.ts index 085f8ae3..7659a8aa 100644 --- a/FrontEnd/src/utilities/index.ts +++ b/FrontEnd/src/utilities/index.ts @@ -8,3 +8,20 @@ export function delay(milliseconds: number): Promise { }, milliseconds); }); } + +export function range(stop: number): number[]; +export function range(start: number, stop: number, step?: number): number[]; +export function range(start: number, stop?: number, step?: number): number[] { + if (stop == undefined) { + stop = start; + start = 0; + } + if (step == undefined) { + step = 1; + } + const result: number[] = []; + for (let i = start; i < stop; i += step) { + result.push(i); + } + return result; +} -- cgit v1.2.3 From eac7cd75a016028d919d7c47e4fb2efa5caacb75 Mon Sep 17 00:00:00 2001 From: crupest Date: Wed, 30 Aug 2023 00:55:11 +0800 Subject: Fix #1392. --- FrontEnd/src/components/SearchInput.css | 4 ++-- FrontEnd/src/components/SearchInput.tsx | 2 +- FrontEnd/src/pages/timeline/TimelineCard.tsx | 2 ++ 3 files changed, 5 insertions(+), 3 deletions(-) (limited to 'FrontEnd/src/components') diff --git a/FrontEnd/src/components/SearchInput.css b/FrontEnd/src/components/SearchInput.css index f0503016..818b2917 100644 --- a/FrontEnd/src/components/SearchInput.css +++ b/FrontEnd/src/components/SearchInput.css @@ -1,8 +1,8 @@ .cru-search-input { display: flex; - flex-wrap: wrap; + 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 index 71820bfa..b1de6227 100644 --- a/FrontEnd/src/components/SearchInput.tsx +++ b/FrontEnd/src/components/SearchInput.tsx @@ -27,7 +27,7 @@ export default function SearchInput({ return (
{ diff --git a/FrontEnd/src/pages/timeline/TimelineCard.tsx b/FrontEnd/src/pages/timeline/TimelineCard.tsx index 133f1ef4..44dfbf9e 100644 --- a/FrontEnd/src/pages/timeline/TimelineCard.tsx +++ b/FrontEnd/src/pages/timeline/TimelineCard.tsx @@ -75,6 +75,7 @@ export default function TimelineCard(props: TimelinePageCardProps) { {user && ( { getHttpBookmarkClient() @@ -96,6 +97,7 @@ export default function TimelineCard(props: TimelinePageCardProps) { )} -- cgit v1.2.3 From f8acad20ddb9f5de156d81b54233571b8efba7f9 Mon Sep 17 00:00:00 2001 From: crupest Date: Wed, 30 Aug 2023 00:58:39 +0800 Subject: ... --- FrontEnd/src/components/Icon.css | 1 + FrontEnd/src/components/Icon.tsx | 6 ++---- 2 files changed, 3 insertions(+), 4 deletions(-) (limited to 'FrontEnd/src/components') diff --git a/FrontEnd/src/components/Icon.css b/FrontEnd/src/components/Icon.css index fe980d7b..3c83b0e9 100644 --- a/FrontEnd/src/components/Icon.css +++ b/FrontEnd/src/components/Icon.css @@ -1,3 +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 index 2ac3a7ca..e5cf598e 100644 --- a/FrontEnd/src/components/Icon.tsx +++ b/FrontEnd/src/components/Icon.tsx @@ -7,20 +7,18 @@ import "./Icon.css"; interface IconButtonProps extends ComponentPropsWithoutRef<"i"> { icon: string; - color?: ThemeColor | "on-surface"; + color?: ThemeColor; size?: string | number; } export default function Icon(props: IconButtonProps) { const { icon, color, size, style, className, ...otherProps } = props; - const colorName = color === "on-surface" ? "surface-on" : color; - return ( Date: Wed, 30 Aug 2023 01:07:00 +0800 Subject: ... --- FrontEnd/src/components/list/ListContainer.tsx | 2 +- FrontEnd/src/components/list/ListItemContainer.css | 6 +++++- FrontEnd/src/pages/timeline/TimelineMember.css | 2 +- FrontEnd/src/pages/timeline/TimelineMember.tsx | 6 +++--- 4 files changed, 10 insertions(+), 6 deletions(-) (limited to 'FrontEnd/src/components') diff --git a/FrontEnd/src/components/list/ListContainer.tsx b/FrontEnd/src/components/list/ListContainer.tsx index aa00d12c..c27e67d4 100644 --- a/FrontEnd/src/components/list/ListContainer.tsx +++ b/FrontEnd/src/components/list/ListContainer.tsx @@ -1,7 +1,7 @@ import { ComponentPropsWithoutRef, forwardRef, Ref } from "react"; import classNames from "classnames"; -import "./ListContainer.css" +import "./ListContainer.css"; function _ListContainer( { className, children, ...otherProps }: ComponentPropsWithoutRef<"div">, diff --git a/FrontEnd/src/components/list/ListItemContainer.css b/FrontEnd/src/components/list/ListItemContainer.css index 8d7afa9f..49468bc2 100644 --- a/FrontEnd/src/components/list/ListItemContainer.css +++ b/FrontEnd/src/components/list/ListItemContainer.css @@ -1,3 +1,7 @@ .cru-list-item-container { - border: 1px solid var(--cru-clickable-primary-normal-color); + border-bottom: 1px solid var(--cru-clickable-primary-normal-color); +} + +.cru-list-item-container:last-child { + border-bottom: none; } diff --git a/FrontEnd/src/pages/timeline/TimelineMember.css b/FrontEnd/src/pages/timeline/TimelineMember.css index 93fcffce..3ad74c57 100644 --- a/FrontEnd/src/pages/timeline/TimelineMember.css +++ b/FrontEnd/src/pages/timeline/TimelineMember.css @@ -1,7 +1,7 @@ .timeline-member-item { align-items: center; display: flex; - padding: 0.5em; + padding: 0.6em; } .timeline-member-avatar { diff --git a/FrontEnd/src/pages/timeline/TimelineMember.tsx b/FrontEnd/src/pages/timeline/TimelineMember.tsx index a25fe6a9..0812016f 100644 --- a/FrontEnd/src/pages/timeline/TimelineMember.tsx +++ b/FrontEnd/src/pages/timeline/TimelineMember.tsx @@ -9,7 +9,7 @@ import { getHttpTimelineClient, HttpTimelineInfo } from "~src/http/timeline"; import SearchInput from "~src/components/SearchInput"; import UserAvatar from "~src/components/user/UserAvatar"; -import Button from "~src/components/button/Button"; +import { IconButton } from "~src/components/button"; import { ListContainer, ListItemContainer } from "~src/components/list"; import "./TimelineMember.css"; @@ -34,8 +34,8 @@ function TimelineMemberItem({
{onAction ? (
-