From 78f0934815a87573289c8e52af2666ea38c93251 Mon Sep 17 00:00:00 2001 From: crupest Date: Tue, 11 Jul 2023 18:45:25 +0800 Subject: Fix dialog typo. --- FrontEnd/src/views/admin/UserAdmin.tsx | 2 +- FrontEnd/src/views/center/TimelineCreateDialog.tsx | 2 +- FrontEnd/src/views/common/dailog/ConfirmDialog.tsx | 43 -- FrontEnd/src/views/common/dailog/Dialog.css | 55 --- FrontEnd/src/views/common/dailog/Dialog.tsx | 46 -- .../src/views/common/dailog/FullPageDialog.css | 44 -- .../src/views/common/dailog/FullPageDialog.tsx | 53 -- .../src/views/common/dailog/OperationDialog.css | 25 - .../src/views/common/dailog/OperationDialog.tsx | 531 --------------------- FrontEnd/src/views/common/dialog/ConfirmDialog.tsx | 43 ++ FrontEnd/src/views/common/dialog/Dialog.css | 55 +++ FrontEnd/src/views/common/dialog/Dialog.tsx | 46 ++ .../src/views/common/dialog/FullPageDialog.css | 44 ++ .../src/views/common/dialog/FullPageDialog.tsx | 53 ++ .../src/views/common/dialog/OperationDialog.css | 25 + .../src/views/common/dialog/OperationDialog.tsx | 531 +++++++++++++++++++++ FrontEnd/src/views/settings/ChangeAvatarDialog.tsx | 2 +- .../src/views/settings/ChangeNicknameDialog.tsx | 2 +- .../src/views/settings/ChangePasswordDialog.tsx | 2 +- FrontEnd/src/views/settings/index.tsx | 2 +- FrontEnd/src/views/timeline/MarkdownPostEdit.tsx | 2 +- .../views/timeline/PostPropertyChangeDialog.tsx | 2 +- FrontEnd/src/views/timeline/TimelineCard.tsx | 2 +- .../src/views/timeline/TimelineDeleteDialog.tsx | 2 +- FrontEnd/src/views/timeline/TimelineMember.tsx | 2 +- FrontEnd/src/views/timeline/TimelinePostView.tsx | 2 +- .../timeline/TimelinePropertyChangeDialog.tsx | 2 +- 27 files changed, 810 insertions(+), 810 deletions(-) delete mode 100644 FrontEnd/src/views/common/dailog/ConfirmDialog.tsx delete mode 100644 FrontEnd/src/views/common/dailog/Dialog.css delete mode 100644 FrontEnd/src/views/common/dailog/Dialog.tsx delete mode 100644 FrontEnd/src/views/common/dailog/FullPageDialog.css delete mode 100644 FrontEnd/src/views/common/dailog/FullPageDialog.tsx delete mode 100644 FrontEnd/src/views/common/dailog/OperationDialog.css delete mode 100644 FrontEnd/src/views/common/dailog/OperationDialog.tsx create mode 100644 FrontEnd/src/views/common/dialog/ConfirmDialog.tsx create mode 100644 FrontEnd/src/views/common/dialog/Dialog.css create mode 100644 FrontEnd/src/views/common/dialog/Dialog.tsx create mode 100644 FrontEnd/src/views/common/dialog/FullPageDialog.css create mode 100644 FrontEnd/src/views/common/dialog/FullPageDialog.tsx create mode 100644 FrontEnd/src/views/common/dialog/OperationDialog.css create mode 100644 FrontEnd/src/views/common/dialog/OperationDialog.tsx (limited to 'FrontEnd/src/views') diff --git a/FrontEnd/src/views/admin/UserAdmin.tsx b/FrontEnd/src/views/admin/UserAdmin.tsx index f7337c81..d5179bf5 100644 --- a/FrontEnd/src/views/admin/UserAdmin.tsx +++ b/FrontEnd/src/views/admin/UserAdmin.tsx @@ -7,7 +7,7 @@ import { getHttpUserClient, HttpUser, kUserPermissionList } from "@/http/user"; import OperationDialog, { OperationDialogBoolInput, -} from "../common/dailog/OperationDialog"; +} from "../common/dialog/OperationDialog"; import Button from "../common/button/Button"; import Spinner from "../common/Spinner"; import FlatButton from "../common/button/FlatButton"; diff --git a/FrontEnd/src/views/center/TimelineCreateDialog.tsx b/FrontEnd/src/views/center/TimelineCreateDialog.tsx index b0e2f59e..63742936 100644 --- a/FrontEnd/src/views/center/TimelineCreateDialog.tsx +++ b/FrontEnd/src/views/center/TimelineCreateDialog.tsx @@ -4,7 +4,7 @@ import { useNavigate } from "react-router-dom"; import { validateTimelineName } from "@/services/timeline"; import { getHttpTimelineClient, HttpTimelineInfo } from "@/http/timeline"; -import OperationDialog from "../common/dailog/OperationDialog"; +import OperationDialog from "../common/dialog/OperationDialog"; import { useUserLoggedIn } from "@/services/user"; interface TimelineCreateDialogProps { diff --git a/FrontEnd/src/views/common/dailog/ConfirmDialog.tsx b/FrontEnd/src/views/common/dailog/ConfirmDialog.tsx deleted file mode 100644 index 8c2cea5a..00000000 --- a/FrontEnd/src/views/common/dailog/ConfirmDialog.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { convertI18nText, I18nText } from "@/common"; -import * as React from "react"; -import { useTranslation } from "react-i18next"; - -import Button from "../button/Button"; -import Dialog from "./Dialog"; - -const ConfirmDialog: React.FC<{ - open: boolean; - onClose: () => void; - onConfirm: () => void; - title: I18nText; - body: I18nText; -}> = ({ open, onClose, onConfirm, title, body }) => { - const { t } = useTranslation(); - - return ( - -

{convertI18nText(title, t)}

-
-

{convertI18nText(body, t)}

-
-
-
-
- ); -}; - -export default ConfirmDialog; diff --git a/FrontEnd/src/views/common/dailog/Dialog.css b/FrontEnd/src/views/common/dailog/Dialog.css deleted file mode 100644 index 21ea52fc..00000000 --- a/FrontEnd/src/views/common/dailog/Dialog.css +++ /dev/null @@ -1,55 +0,0 @@ -.cru-dialog-overlay { - position: fixed; - z-index: 1040; - left: 0; - top: 0; - right: 0; - bottom: 0; - background-color: rgba(255, 255, 255, 0.92); - - display: flex; - padding: 2em; - - overflow: auto; -} - -.cru-dialog-container { - max-width: 100%; - min-width: 30vw; - - margin: auto; - - border: var(--cru-primary-color) 1px solid; - border-radius: 5px; - padding: 1.5em; - background-color: white; -} - -.cru-dialog-bottom-area { - display: flex; - justify-content: flex-end; -} - -.cru-dialog-bottom-area > * { - margin: 0 0.5em; -} - -.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; -} diff --git a/FrontEnd/src/views/common/dailog/Dialog.tsx b/FrontEnd/src/views/common/dailog/Dialog.tsx deleted file mode 100644 index c755950d..00000000 --- a/FrontEnd/src/views/common/dailog/Dialog.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import * as React from "react"; -import ReactDOM from "react-dom"; -import { CSSTransition } from "react-transition-group"; - -import "./Dialog.css"; - -export interface DialogProps { - onClose: () => void; - open: boolean; - children?: React.ReactNode; - disableCloseOnClickOnOverlay?: boolean; -} - -export default function Dialog(props: DialogProps): React.ReactElement | null { - const { open, onClose, children, disableCloseOnClickOnOverlay } = props; - - return ReactDOM.createPortal( - -
{ - onClose(); - } - } - > -
e.stopPropagation()} - > - {children} -
-
-
, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - document.getElementById("portal")! - ); -} diff --git a/FrontEnd/src/views/common/dailog/FullPageDialog.css b/FrontEnd/src/views/common/dailog/FullPageDialog.css deleted file mode 100644 index 2f1fc636..00000000 --- a/FrontEnd/src/views/common/dailog/FullPageDialog.css +++ /dev/null @@ -1,44 +0,0 @@ -.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/views/common/dailog/FullPageDialog.tsx b/FrontEnd/src/views/common/dailog/FullPageDialog.tsx deleted file mode 100644 index 6368fc0a..00000000 --- a/FrontEnd/src/views/common/dailog/FullPageDialog.tsx +++ /dev/null @@ -1,53 +0,0 @@ -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/views/common/dailog/OperationDialog.css b/FrontEnd/src/views/common/dailog/OperationDialog.css deleted file mode 100644 index 2f7617d0..00000000 --- a/FrontEnd/src/views/common/dailog/OperationDialog.css +++ /dev/null @@ -1,25 +0,0 @@ -.cru-operation-dialog-group { - display: block; - margin: 0.4em 0; -} - -.cru-operation-dialog-label { - display: block; - color: var(--cru-primary-color); -} - -.cru-operation-dialog-inline-label { - margin-inline-start: 0.5em; -} - -.cru-operation-dialog-error-text { - display: block; - font-size: 0.8em; - color: var(--cru-danger-color); -} - -.cru-operation-dialog-helper-text { - display: block; - font-size: 0.8em; - color: var(--cru-primary-color); -} diff --git a/FrontEnd/src/views/common/dailog/OperationDialog.tsx b/FrontEnd/src/views/common/dailog/OperationDialog.tsx deleted file mode 100644 index 71be030a..00000000 --- a/FrontEnd/src/views/common/dailog/OperationDialog.tsx +++ /dev/null @@ -1,531 +0,0 @@ -import { useState } from "react"; -import * as React from "react"; -import { useTranslation } from "react-i18next"; -import { TwitterPicker } from "react-color"; -import classNames from "classnames"; -import moment from "moment"; - -import { convertI18nText, I18nText, UiLogicError } from "@/common"; - -import { PaletteColorType } from "@/palette"; - -import Button from "../button/Button"; -import LoadingButton from "../button/LoadingButton"; -import Dialog from "./Dialog"; - -import "./OperationDialog.css"; - -interface DefaultErrorPromptProps { - error?: string; -} - -const DefaultErrorPrompt: React.FC = (props) => { - const { t } = useTranslation(); - - let result =

{t("operationDialog.error")}

; - - if (props.error != null) { - result = ( - <> - {result} -

{props.error}

- - ); - } - - return result; -}; - -export interface OperationDialogTextInput { - type: "text"; - label?: I18nText; - password?: boolean; - initValue?: string; - textFieldProps?: Omit< - React.InputHTMLAttributes, - "type" | "value" | "onChange" - >; - helperText?: string; -} - -export interface OperationDialogBoolInput { - type: "bool"; - label: I18nText; - initValue?: boolean; - helperText?: string; -} - -export interface OperationDialogSelectInputOption { - value: string; - label: I18nText; - icon?: React.ReactElement; -} - -export interface OperationDialogSelectInput { - type: "select"; - label: I18nText; - options: OperationDialogSelectInputOption[]; - initValue?: string; -} - -export interface OperationDialogColorInput { - type: "color"; - label?: I18nText; - initValue?: string | null; - canBeNull?: boolean; -} - -export interface OperationDialogDateTimeInput { - type: "datetime"; - label?: I18nText; - initValue?: string; - helperText?: string; -} - -export type OperationDialogInput = - | OperationDialogTextInput - | OperationDialogBoolInput - | OperationDialogSelectInput - | OperationDialogColorInput - | OperationDialogDateTimeInput; - -interface OperationInputTypeStringToValueTypeMap { - text: string; - bool: boolean; - select: string; - color: string | null; - datetime: string; -} - -type MapOperationInputTypeStringToValueType = - Type extends keyof OperationInputTypeStringToValueTypeMap - ? OperationInputTypeStringToValueTypeMap[Type] - : never; - -type MapOperationInputInfoValueType = T extends OperationDialogInput - ? MapOperationInputTypeStringToValueType - : T; - -const initValueMapperMap: { - [T in OperationDialogInput as T["type"]]: ( - item: T - ) => MapOperationInputInfoValueType; -} = { - bool: (item) => item.initValue ?? false, - color: (item) => item.initValue ?? null, - datetime: (item) => { - if (item.initValue != null) { - return moment(item.initValue).format("YYYY-MM-DDTHH:mm:ss"); - } else { - return ""; - } - }, - select: (item) => item.initValue ?? item.options[0].value, - text: (item) => item.initValue ?? "", -}; - -type MapOperationInputInfoValueTypeList< - Tuple extends readonly OperationDialogInput[] -> = { - [Index in keyof Tuple]: MapOperationInputInfoValueType; -} & { length: Tuple["length"] }; - -export type OperationInputError = - | { - [index: number]: I18nText | null | undefined; - } - | null - | undefined; - -const isNoError = (error: OperationInputError): boolean => { - if (error == null) return true; - for (const key in error) { - if (error[key] != null) return false; - } - return true; -}; - -export interface OperationDialogProps< - TData, - OperationInputInfoList extends readonly OperationDialogInput[] -> { - open: boolean; - onClose: () => void; - title: I18nText | (() => React.ReactNode); - themeColor?: PaletteColorType; - onProcess: ( - inputs: MapOperationInputInfoValueTypeList - ) => Promise; - inputScheme?: OperationInputInfoList; - inputValidator?: ( - inputs: MapOperationInputInfoValueTypeList - ) => OperationInputError; - inputPrompt?: I18nText | (() => React.ReactNode); - processPrompt?: () => React.ReactNode; - successPrompt?: (data: TData) => React.ReactNode; - failurePrompt?: (error: unknown) => React.ReactNode; - onSuccessAndClose?: (data: TData) => void; -} - -const OperationDialog = < - TData, - OperationInputInfoList extends readonly OperationDialogInput[] ->( - props: OperationDialogProps -): React.ReactElement => { - const inputScheme = (props.inputScheme ?? - []) as readonly OperationDialogInput[]; - - const { t } = useTranslation(); - - type Step = - | { type: "input" } - | { type: "process" } - | { - type: "success"; - data: TData; - } - | { - type: "failure"; - data: unknown; - }; - const [step, setStep] = useState({ type: "input" }); - - type ValueType = boolean | string | null | undefined; - - const [values, setValues] = useState( - inputScheme.map((item) => { - if (item.type in initValueMapperMap) { - return ( - initValueMapperMap[item.type] as ( - i: OperationDialogInput - ) => ValueType - )(item); - } else { - throw new UiLogicError("Unknown input scheme."); - } - }) - ); - const [dirtyList, setDirtyList] = useState(() => - inputScheme.map(() => false) - ); - const [inputError, setInputError] = useState(); - - const close = (): void => { - if (step.type !== "process") { - props.onClose(); - if (step.type === "success" && props.onSuccessAndClose) { - props.onSuccessAndClose(step.data); - } - } else { - console.log("Attempt to close modal when processing."); - } - }; - - const onConfirm = (): void => { - setStep({ type: "process" }); - props - .onProcess( - values.map((v, index) => { - if (inputScheme[index].type === "datetime" && v !== "") - return new Date(v as string).toISOString(); - else return v; - }) as unknown as MapOperationInputInfoValueTypeList - ) - .then( - (d) => { - setStep({ - type: "success", - data: d, - }); - }, - (e: unknown) => { - setStep({ - type: "failure", - data: e, - }); - } - ); - }; - - let body: React.ReactNode; - if (step.type === "input" || step.type === "process") { - const process = step.type === "process"; - - let inputPrompt = - typeof props.inputPrompt === "function" - ? props.inputPrompt() - : convertI18nText(props.inputPrompt, t); - inputPrompt =
{inputPrompt}
; - - const validate = (values: ValueType[]): boolean => { - const { inputValidator } = props; - if (inputValidator != null) { - const result = inputValidator( - values as unknown as MapOperationInputInfoValueTypeList - ); - setInputError(result); - return isNoError(result); - } - return true; - }; - - const updateValue = (index: number, newValue: ValueType): void => { - const oldValues = values; - const newValues = oldValues.slice(); - newValues[index] = newValue; - setValues(newValues); - if (dirtyList[index] === false) { - const newDirtyList = dirtyList.slice(); - newDirtyList[index] = true; - setDirtyList(newDirtyList); - } - validate(newValues); - }; - - const canProcess = isNoError(inputError); - - body = ( - <> -
- {inputPrompt} - {inputScheme.map((item, index) => { - const value = values[index]; - const error: string | null = - dirtyList[index] && inputError != null - ? convertI18nText(inputError[index], t) - : null; - - if (item.type === "text") { - return ( -
- {item.label && ( - - )} - { - const v = e.target.value; - updateValue(index, v); - }} - disabled={process} - /> - {error != null && ( -
- {error} -
- )} - {item.helperText && ( -
- {t(item.helperText)} -
- )} -
- ); - } else if (item.type === "bool") { - return ( -
- { - updateValue(index, event.currentTarget.checked); - }} - disabled={process} - /> - - {error != null && ( -
- {error} -
- )} - {item.helperText && ( -
- {t(item.helperText)} -
- )} -
- ); - } else if (item.type === "select") { - return ( -
- - -
- ); - } else if (item.type === "color") { - return ( -
- {item.canBeNull ? ( - { - if (event.currentTarget.checked) { - updateValue(index, "#007bff"); - } else { - updateValue(index, null); - } - }} - disabled={process} - /> - ) : null} - - {value !== null && ( - updateValue(index, result.hex)} - /> - )} -
- ); - } else if (item.type === "datetime") { - return ( -
- {item.label && ( - - )} - { - const v = e.target.value; - updateValue(index, v); - }} - disabled={process} - /> - {error != null &&
{error}
} -
- ); - } - })} -
-
-
-
- - ); - } else { - let content: React.ReactNode; - const result = step; - if (result.type === "success") { - content = - props.successPrompt?.(result.data) ?? t("operationDialog.success"); - if (typeof content === "string") - content =

{content}

; - } else { - content = props.failurePrompt?.(result.data) ?? ; - if (typeof content === "string") - content = ; - } - body = ( - <> -
{content}
-
-
-
- - ); - } - - const title = - typeof props.title === "function" - ? props.title() - : convertI18nText(props.title, t); - - return ( - -

- {title} -

-
- {body} -
- ); -}; - -export default OperationDialog; diff --git a/FrontEnd/src/views/common/dialog/ConfirmDialog.tsx b/FrontEnd/src/views/common/dialog/ConfirmDialog.tsx new file mode 100644 index 00000000..8c2cea5a --- /dev/null +++ b/FrontEnd/src/views/common/dialog/ConfirmDialog.tsx @@ -0,0 +1,43 @@ +import { convertI18nText, I18nText } from "@/common"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; + +import Button from "../button/Button"; +import Dialog from "./Dialog"; + +const ConfirmDialog: React.FC<{ + open: boolean; + onClose: () => void; + onConfirm: () => void; + title: I18nText; + body: I18nText; +}> = ({ open, onClose, onConfirm, title, body }) => { + const { t } = useTranslation(); + + return ( + +

{convertI18nText(title, t)}

+
+

{convertI18nText(body, t)}

+
+
+
+
+ ); +}; + +export default ConfirmDialog; diff --git a/FrontEnd/src/views/common/dialog/Dialog.css b/FrontEnd/src/views/common/dialog/Dialog.css new file mode 100644 index 00000000..21ea52fc --- /dev/null +++ b/FrontEnd/src/views/common/dialog/Dialog.css @@ -0,0 +1,55 @@ +.cru-dialog-overlay { + position: fixed; + z-index: 1040; + left: 0; + top: 0; + right: 0; + bottom: 0; + background-color: rgba(255, 255, 255, 0.92); + + display: flex; + padding: 2em; + + overflow: auto; +} + +.cru-dialog-container { + max-width: 100%; + min-width: 30vw; + + margin: auto; + + border: var(--cru-primary-color) 1px solid; + border-radius: 5px; + padding: 1.5em; + background-color: white; +} + +.cru-dialog-bottom-area { + display: flex; + justify-content: flex-end; +} + +.cru-dialog-bottom-area > * { + margin: 0 0.5em; +} + +.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; +} diff --git a/FrontEnd/src/views/common/dialog/Dialog.tsx b/FrontEnd/src/views/common/dialog/Dialog.tsx new file mode 100644 index 00000000..c755950d --- /dev/null +++ b/FrontEnd/src/views/common/dialog/Dialog.tsx @@ -0,0 +1,46 @@ +import * as React from "react"; +import ReactDOM from "react-dom"; +import { CSSTransition } from "react-transition-group"; + +import "./Dialog.css"; + +export interface DialogProps { + onClose: () => void; + open: boolean; + children?: React.ReactNode; + disableCloseOnClickOnOverlay?: boolean; +} + +export default function Dialog(props: DialogProps): React.ReactElement | null { + const { open, onClose, children, disableCloseOnClickOnOverlay } = props; + + return ReactDOM.createPortal( + +
{ + onClose(); + } + } + > +
e.stopPropagation()} + > + {children} +
+
+
, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + document.getElementById("portal")! + ); +} diff --git a/FrontEnd/src/views/common/dialog/FullPageDialog.css b/FrontEnd/src/views/common/dialog/FullPageDialog.css new file mode 100644 index 00000000..2f1fc636 --- /dev/null +++ b/FrontEnd/src/views/common/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/views/common/dialog/FullPageDialog.tsx b/FrontEnd/src/views/common/dialog/FullPageDialog.tsx new file mode 100644 index 00000000..6368fc0a --- /dev/null +++ b/FrontEnd/src/views/common/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/views/common/dialog/OperationDialog.css b/FrontEnd/src/views/common/dialog/OperationDialog.css new file mode 100644 index 00000000..2f7617d0 --- /dev/null +++ b/FrontEnd/src/views/common/dialog/OperationDialog.css @@ -0,0 +1,25 @@ +.cru-operation-dialog-group { + display: block; + margin: 0.4em 0; +} + +.cru-operation-dialog-label { + display: block; + color: var(--cru-primary-color); +} + +.cru-operation-dialog-inline-label { + margin-inline-start: 0.5em; +} + +.cru-operation-dialog-error-text { + display: block; + font-size: 0.8em; + color: var(--cru-danger-color); +} + +.cru-operation-dialog-helper-text { + display: block; + font-size: 0.8em; + color: var(--cru-primary-color); +} diff --git a/FrontEnd/src/views/common/dialog/OperationDialog.tsx b/FrontEnd/src/views/common/dialog/OperationDialog.tsx new file mode 100644 index 00000000..71be030a --- /dev/null +++ b/FrontEnd/src/views/common/dialog/OperationDialog.tsx @@ -0,0 +1,531 @@ +import { useState } from "react"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import { TwitterPicker } from "react-color"; +import classNames from "classnames"; +import moment from "moment"; + +import { convertI18nText, I18nText, UiLogicError } from "@/common"; + +import { PaletteColorType } from "@/palette"; + +import Button from "../button/Button"; +import LoadingButton from "../button/LoadingButton"; +import Dialog from "./Dialog"; + +import "./OperationDialog.css"; + +interface DefaultErrorPromptProps { + error?: string; +} + +const DefaultErrorPrompt: React.FC = (props) => { + const { t } = useTranslation(); + + let result =

{t("operationDialog.error")}

; + + if (props.error != null) { + result = ( + <> + {result} +

{props.error}

+ + ); + } + + return result; +}; + +export interface OperationDialogTextInput { + type: "text"; + label?: I18nText; + password?: boolean; + initValue?: string; + textFieldProps?: Omit< + React.InputHTMLAttributes, + "type" | "value" | "onChange" + >; + helperText?: string; +} + +export interface OperationDialogBoolInput { + type: "bool"; + label: I18nText; + initValue?: boolean; + helperText?: string; +} + +export interface OperationDialogSelectInputOption { + value: string; + label: I18nText; + icon?: React.ReactElement; +} + +export interface OperationDialogSelectInput { + type: "select"; + label: I18nText; + options: OperationDialogSelectInputOption[]; + initValue?: string; +} + +export interface OperationDialogColorInput { + type: "color"; + label?: I18nText; + initValue?: string | null; + canBeNull?: boolean; +} + +export interface OperationDialogDateTimeInput { + type: "datetime"; + label?: I18nText; + initValue?: string; + helperText?: string; +} + +export type OperationDialogInput = + | OperationDialogTextInput + | OperationDialogBoolInput + | OperationDialogSelectInput + | OperationDialogColorInput + | OperationDialogDateTimeInput; + +interface OperationInputTypeStringToValueTypeMap { + text: string; + bool: boolean; + select: string; + color: string | null; + datetime: string; +} + +type MapOperationInputTypeStringToValueType = + Type extends keyof OperationInputTypeStringToValueTypeMap + ? OperationInputTypeStringToValueTypeMap[Type] + : never; + +type MapOperationInputInfoValueType = T extends OperationDialogInput + ? MapOperationInputTypeStringToValueType + : T; + +const initValueMapperMap: { + [T in OperationDialogInput as T["type"]]: ( + item: T + ) => MapOperationInputInfoValueType; +} = { + bool: (item) => item.initValue ?? false, + color: (item) => item.initValue ?? null, + datetime: (item) => { + if (item.initValue != null) { + return moment(item.initValue).format("YYYY-MM-DDTHH:mm:ss"); + } else { + return ""; + } + }, + select: (item) => item.initValue ?? item.options[0].value, + text: (item) => item.initValue ?? "", +}; + +type MapOperationInputInfoValueTypeList< + Tuple extends readonly OperationDialogInput[] +> = { + [Index in keyof Tuple]: MapOperationInputInfoValueType; +} & { length: Tuple["length"] }; + +export type OperationInputError = + | { + [index: number]: I18nText | null | undefined; + } + | null + | undefined; + +const isNoError = (error: OperationInputError): boolean => { + if (error == null) return true; + for (const key in error) { + if (error[key] != null) return false; + } + return true; +}; + +export interface OperationDialogProps< + TData, + OperationInputInfoList extends readonly OperationDialogInput[] +> { + open: boolean; + onClose: () => void; + title: I18nText | (() => React.ReactNode); + themeColor?: PaletteColorType; + onProcess: ( + inputs: MapOperationInputInfoValueTypeList + ) => Promise; + inputScheme?: OperationInputInfoList; + inputValidator?: ( + inputs: MapOperationInputInfoValueTypeList + ) => OperationInputError; + inputPrompt?: I18nText | (() => React.ReactNode); + processPrompt?: () => React.ReactNode; + successPrompt?: (data: TData) => React.ReactNode; + failurePrompt?: (error: unknown) => React.ReactNode; + onSuccessAndClose?: (data: TData) => void; +} + +const OperationDialog = < + TData, + OperationInputInfoList extends readonly OperationDialogInput[] +>( + props: OperationDialogProps +): React.ReactElement => { + const inputScheme = (props.inputScheme ?? + []) as readonly OperationDialogInput[]; + + const { t } = useTranslation(); + + type Step = + | { type: "input" } + | { type: "process" } + | { + type: "success"; + data: TData; + } + | { + type: "failure"; + data: unknown; + }; + const [step, setStep] = useState({ type: "input" }); + + type ValueType = boolean | string | null | undefined; + + const [values, setValues] = useState( + inputScheme.map((item) => { + if (item.type in initValueMapperMap) { + return ( + initValueMapperMap[item.type] as ( + i: OperationDialogInput + ) => ValueType + )(item); + } else { + throw new UiLogicError("Unknown input scheme."); + } + }) + ); + const [dirtyList, setDirtyList] = useState(() => + inputScheme.map(() => false) + ); + const [inputError, setInputError] = useState(); + + const close = (): void => { + if (step.type !== "process") { + props.onClose(); + if (step.type === "success" && props.onSuccessAndClose) { + props.onSuccessAndClose(step.data); + } + } else { + console.log("Attempt to close modal when processing."); + } + }; + + const onConfirm = (): void => { + setStep({ type: "process" }); + props + .onProcess( + values.map((v, index) => { + if (inputScheme[index].type === "datetime" && v !== "") + return new Date(v as string).toISOString(); + else return v; + }) as unknown as MapOperationInputInfoValueTypeList + ) + .then( + (d) => { + setStep({ + type: "success", + data: d, + }); + }, + (e: unknown) => { + setStep({ + type: "failure", + data: e, + }); + } + ); + }; + + let body: React.ReactNode; + if (step.type === "input" || step.type === "process") { + const process = step.type === "process"; + + let inputPrompt = + typeof props.inputPrompt === "function" + ? props.inputPrompt() + : convertI18nText(props.inputPrompt, t); + inputPrompt =
{inputPrompt}
; + + const validate = (values: ValueType[]): boolean => { + const { inputValidator } = props; + if (inputValidator != null) { + const result = inputValidator( + values as unknown as MapOperationInputInfoValueTypeList + ); + setInputError(result); + return isNoError(result); + } + return true; + }; + + const updateValue = (index: number, newValue: ValueType): void => { + const oldValues = values; + const newValues = oldValues.slice(); + newValues[index] = newValue; + setValues(newValues); + if (dirtyList[index] === false) { + const newDirtyList = dirtyList.slice(); + newDirtyList[index] = true; + setDirtyList(newDirtyList); + } + validate(newValues); + }; + + const canProcess = isNoError(inputError); + + body = ( + <> +
+ {inputPrompt} + {inputScheme.map((item, index) => { + const value = values[index]; + const error: string | null = + dirtyList[index] && inputError != null + ? convertI18nText(inputError[index], t) + : null; + + if (item.type === "text") { + return ( +
+ {item.label && ( + + )} + { + const v = e.target.value; + updateValue(index, v); + }} + disabled={process} + /> + {error != null && ( +
+ {error} +
+ )} + {item.helperText && ( +
+ {t(item.helperText)} +
+ )} +
+ ); + } else if (item.type === "bool") { + return ( +
+ { + updateValue(index, event.currentTarget.checked); + }} + disabled={process} + /> + + {error != null && ( +
+ {error} +
+ )} + {item.helperText && ( +
+ {t(item.helperText)} +
+ )} +
+ ); + } else if (item.type === "select") { + return ( +
+ + +
+ ); + } else if (item.type === "color") { + return ( +
+ {item.canBeNull ? ( + { + if (event.currentTarget.checked) { + updateValue(index, "#007bff"); + } else { + updateValue(index, null); + } + }} + disabled={process} + /> + ) : null} + + {value !== null && ( + updateValue(index, result.hex)} + /> + )} +
+ ); + } else if (item.type === "datetime") { + return ( +
+ {item.label && ( + + )} + { + const v = e.target.value; + updateValue(index, v); + }} + disabled={process} + /> + {error != null &&
{error}
} +
+ ); + } + })} +
+
+
+
+ + ); + } else { + let content: React.ReactNode; + const result = step; + if (result.type === "success") { + content = + props.successPrompt?.(result.data) ?? t("operationDialog.success"); + if (typeof content === "string") + content =

{content}

; + } else { + content = props.failurePrompt?.(result.data) ?? ; + if (typeof content === "string") + content = ; + } + body = ( + <> +
{content}
+
+
+
+ + ); + } + + const title = + typeof props.title === "function" + ? props.title() + : convertI18nText(props.title, t); + + return ( + +

+ {title} +

+
+ {body} +
+ ); +}; + +export default OperationDialog; diff --git a/FrontEnd/src/views/settings/ChangeAvatarDialog.tsx b/FrontEnd/src/views/settings/ChangeAvatarDialog.tsx index ddca342a..44bd2c68 100644 --- a/FrontEnd/src/views/settings/ChangeAvatarDialog.tsx +++ b/FrontEnd/src/views/settings/ChangeAvatarDialog.tsx @@ -11,7 +11,7 @@ import { getHttpUserClient } from "@/http/user"; import ImageCropper, { Clip, applyClipToImage } from "../common/ImageCropper"; import Button from "../common/button/Button"; -import Dialog from "../common/dailog/Dialog"; +import Dialog from "../common/dialog/Dialog"; export interface ChangeAvatarDialogProps { open: boolean; diff --git a/FrontEnd/src/views/settings/ChangeNicknameDialog.tsx b/FrontEnd/src/views/settings/ChangeNicknameDialog.tsx index 8cd881ce..7ba12de8 100644 --- a/FrontEnd/src/views/settings/ChangeNicknameDialog.tsx +++ b/FrontEnd/src/views/settings/ChangeNicknameDialog.tsx @@ -2,7 +2,7 @@ import { getHttpUserClient } from "@/http/user"; import { useUser } from "@/services/user"; import * as React from "react"; -import OperationDialog from "../common/dailog/OperationDialog"; +import OperationDialog from "../common/dialog/OperationDialog"; export interface ChangeNicknameDialogProps { open: boolean; diff --git a/FrontEnd/src/views/settings/ChangePasswordDialog.tsx b/FrontEnd/src/views/settings/ChangePasswordDialog.tsx index bfc03e5e..a34ca4a7 100644 --- a/FrontEnd/src/views/settings/ChangePasswordDialog.tsx +++ b/FrontEnd/src/views/settings/ChangePasswordDialog.tsx @@ -4,7 +4,7 @@ import { useNavigate } from "react-router-dom"; import { userService } from "@/services/user"; -import OperationDialog from "../common/dailog/OperationDialog"; +import OperationDialog from "../common/dialog/OperationDialog"; export interface ChangePasswordDialogProps { open: boolean; diff --git a/FrontEnd/src/views/settings/index.tsx b/FrontEnd/src/views/settings/index.tsx index ccaf86d2..6647826f 100644 --- a/FrontEnd/src/views/settings/index.tsx +++ b/FrontEnd/src/views/settings/index.tsx @@ -9,7 +9,7 @@ import { useUser, userService } from "@/services/user"; import { getHttpUserClient } from "@/http/user"; import { TimelineVisibility } from "@/http/timeline"; -import ConfirmDialog from "../common/dailog/ConfirmDialog"; +import ConfirmDialog from "../common/dialog/ConfirmDialog"; import Card from "../common/Card"; import Spinner from "../common/Spinner"; import ChangePasswordDialog from "./ChangePasswordDialog"; diff --git a/FrontEnd/src/views/timeline/MarkdownPostEdit.tsx b/FrontEnd/src/views/timeline/MarkdownPostEdit.tsx index a4f7924e..6401cfaa 100644 --- a/FrontEnd/src/views/timeline/MarkdownPostEdit.tsx +++ b/FrontEnd/src/views/timeline/MarkdownPostEdit.tsx @@ -8,7 +8,7 @@ import TimelinePostBuilder from "@/services/TimelinePostBuilder"; import FlatButton from "../common/button/FlatButton"; import TabPages from "../common/tab/TabPages"; -import ConfirmDialog from "../common/dailog/ConfirmDialog"; +import ConfirmDialog from "../common/dialog/ConfirmDialog"; import Spinner from "../common/Spinner"; import IconButton from "../common/button/IconButton"; diff --git a/FrontEnd/src/views/timeline/PostPropertyChangeDialog.tsx b/FrontEnd/src/views/timeline/PostPropertyChangeDialog.tsx index 90ec82cc..fc55185c 100644 --- a/FrontEnd/src/views/timeline/PostPropertyChangeDialog.tsx +++ b/FrontEnd/src/views/timeline/PostPropertyChangeDialog.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import { getHttpTimelineClient, HttpTimelinePostInfo } from "@/http/timeline"; -import OperationDialog from "../common/dailog/OperationDialog"; +import OperationDialog from "../common/dialog/OperationDialog"; function PostPropertyChangeDialog(props: { open: boolean; diff --git a/FrontEnd/src/views/timeline/TimelineCard.tsx b/FrontEnd/src/views/timeline/TimelineCard.tsx index 5c9a7d1f..fdf7f0a0 100644 --- a/FrontEnd/src/views/timeline/TimelineCard.tsx +++ b/FrontEnd/src/views/timeline/TimelineCard.tsx @@ -12,7 +12,7 @@ import { getHttpBookmarkClient } from "@/http/bookmark"; import UserAvatar from "../common/user/UserAvatar"; import PopupMenu from "../common/menu/PopupMenu"; -import FullPageDialog from "../common/dailog/FullPageDialog"; +import FullPageDialog from "../common/dialog/FullPageDialog"; import Card from "../common/Card"; import TimelineDeleteDialog from "./TimelineDeleteDialog"; import ConnectionStatusBadge from "./ConnectionStatusBadge"; diff --git a/FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx b/FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx index 77dfdaaf..c960b3c2 100644 --- a/FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx +++ b/FrontEnd/src/views/timeline/TimelineDeleteDialog.tsx @@ -4,7 +4,7 @@ import { Trans } from "react-i18next"; import { getHttpTimelineClient, HttpTimelineInfo } from "@/http/timeline"; -import OperationDialog from "../common/dailog/OperationDialog"; +import OperationDialog from "../common/dialog/OperationDialog"; interface TimelineDeleteDialog { timeline: HttpTimelineInfo; diff --git a/FrontEnd/src/views/timeline/TimelineMember.tsx b/FrontEnd/src/views/timeline/TimelineMember.tsx index a353ae21..aaafd173 100644 --- a/FrontEnd/src/views/timeline/TimelineMember.tsx +++ b/FrontEnd/src/views/timeline/TimelineMember.tsx @@ -11,7 +11,7 @@ import { getHttpTimelineClient, HttpTimelineInfo } from "@/http/timeline"; import SearchInput from "../common/SearchInput"; import UserAvatar from "../common/user/UserAvatar"; import Button from "../common/button/Button"; -import Dialog from "../common/dailog/Dialog"; +import Dialog from "../common/dialog/Dialog"; import "./TimelineMember.css"; diff --git a/FrontEnd/src/views/timeline/TimelinePostView.tsx b/FrontEnd/src/views/timeline/TimelinePostView.tsx index 584f0a68..e3eac0f4 100644 --- a/FrontEnd/src/views/timeline/TimelinePostView.tsx +++ b/FrontEnd/src/views/timeline/TimelinePostView.tsx @@ -10,7 +10,7 @@ import { useClickOutside } from "@/utilities/hooks"; import UserAvatar from "../common/user/UserAvatar"; import Card from "../common/Card"; import FlatButton from "../common/button/FlatButton"; -import ConfirmDialog from "../common/dailog/ConfirmDialog"; +import ConfirmDialog from "../common/dialog/ConfirmDialog"; import TimelineLine from "./TimelineLine"; import TimelinePostContentView from "./TimelinePostContentView"; import PostPropertyChangeDialog from "./PostPropertyChangeDialog"; diff --git a/FrontEnd/src/views/timeline/TimelinePropertyChangeDialog.tsx b/FrontEnd/src/views/timeline/TimelinePropertyChangeDialog.tsx index afd9a32d..63750445 100644 --- a/FrontEnd/src/views/timeline/TimelinePropertyChangeDialog.tsx +++ b/FrontEnd/src/views/timeline/TimelinePropertyChangeDialog.tsx @@ -8,7 +8,7 @@ import { TimelineVisibility, } from "@/http/timeline"; -import OperationDialog from "../common/dailog/OperationDialog"; +import OperationDialog from "../common/dialog/OperationDialog"; export interface TimelinePropertyChangeDialogProps { open: boolean; -- cgit v1.2.3