aboutsummaryrefslogtreecommitdiff
path: root/FrontEnd/src/views/common/dialog
diff options
context:
space:
mode:
authorcrupest <crupest@outlook.com>2023-07-11 18:45:25 +0800
committercrupest <crupest@outlook.com>2023-07-11 18:45:25 +0800
commit78f0934815a87573289c8e52af2666ea38c93251 (patch)
tree2a46597d7fd5ef64e07e557dfba4a62412e96e16 /FrontEnd/src/views/common/dialog
parent82d747f694288db25ebd3f2aef146c8382017158 (diff)
downloadtimeline-78f0934815a87573289c8e52af2666ea38c93251.tar.gz
timeline-78f0934815a87573289c8e52af2666ea38c93251.tar.bz2
timeline-78f0934815a87573289c8e52af2666ea38c93251.zip
Fix dialog typo.
Diffstat (limited to 'FrontEnd/src/views/common/dialog')
-rw-r--r--FrontEnd/src/views/common/dialog/ConfirmDialog.tsx43
-rw-r--r--FrontEnd/src/views/common/dialog/Dialog.css55
-rw-r--r--FrontEnd/src/views/common/dialog/Dialog.tsx46
-rw-r--r--FrontEnd/src/views/common/dialog/FullPageDialog.css44
-rw-r--r--FrontEnd/src/views/common/dialog/FullPageDialog.tsx53
-rw-r--r--FrontEnd/src/views/common/dialog/OperationDialog.css25
-rw-r--r--FrontEnd/src/views/common/dialog/OperationDialog.tsx531
7 files changed, 797 insertions, 0 deletions
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 (
+ <Dialog onClose={onClose} open={open}>
+ <h3 className="cru-color-danger">{convertI18nText(title, t)}</h3>
+ <hr />
+ <p>{convertI18nText(body, t)}</p>
+ <hr />
+ <div className="cru-dialog-bottom-area">
+ <Button
+ text="operationDialog.cancel"
+ color="secondary"
+ outline
+ onClick={onClose}
+ />
+ <Button
+ text="operationDialog.confirm"
+ color="danger"
+ onClick={() => {
+ onConfirm();
+ onClose();
+ }}
+ />
+ </div>
+ </Dialog>
+ );
+};
+
+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(
+ <CSSTransition
+ mountOnEnter
+ unmountOnExit
+ in={open}
+ timeout={300}
+ classNames="cru-dialog"
+ >
+ <div
+ className="cru-dialog-overlay"
+ onClick={
+ disableCloseOnClickOnOverlay
+ ? undefined
+ : () => {
+ onClose();
+ }
+ }
+ >
+ <div
+ className="cru-dialog-container"
+ onClick={(e) => e.stopPropagation()}
+ >
+ {children}
+ </div>
+ </div>
+ </CSSTransition>,
+ // 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<FullPageDialogProps> = ({
+ show,
+ onBack,
+ children,
+ contentContainerClassName,
+}) => {
+ return createPortal(
+ <CSSTransition
+ mountOnEnter
+ unmountOnExit
+ in={show}
+ timeout={300}
+ classNames="cru-full-page"
+ >
+ <div className="cru-full-page">
+ <div className="cru-full-page-top-bar">
+ <IconButton
+ icon="arrow-left"
+ className="ms-3 cru-full-page-back-button"
+ onClick={onBack}
+ />
+ </div>
+ <div
+ className={classnames(
+ "cru-full-page-content-container",
+ contentContainerClassName
+ )}
+ >
+ {children}
+ </div>
+ </div>
+ </CSSTransition>,
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ document.getElementById("portal")!
+ );
+};
+
+export default FullPageDialog;
diff --git a/FrontEnd/src/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<DefaultErrorPromptProps> = (props) => {
+ const { t } = useTranslation();
+
+ let result = <p className="cru-color-danger">{t("operationDialog.error")}</p>;
+
+ if (props.error != null) {
+ result = (
+ <>
+ {result}
+ <p className="cru-color-danger">{props.error}</p>
+ </>
+ );
+ }
+
+ return result;
+};
+
+export interface OperationDialogTextInput {
+ type: "text";
+ label?: I18nText;
+ password?: boolean;
+ initValue?: string;
+ textFieldProps?: Omit<
+ React.InputHTMLAttributes<HTMLInputElement>,
+ "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> =
+ Type extends keyof OperationInputTypeStringToValueTypeMap
+ ? OperationInputTypeStringToValueTypeMap[Type]
+ : never;
+
+type MapOperationInputInfoValueType<T> = T extends OperationDialogInput
+ ? MapOperationInputTypeStringToValueType<T["type"]>
+ : T;
+
+const initValueMapperMap: {
+ [T in OperationDialogInput as T["type"]]: (
+ item: T
+ ) => MapOperationInputInfoValueType<T>;
+} = {
+ 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<Tuple[Index]>;
+} & { 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<OperationInputInfoList>
+ ) => Promise<TData>;
+ inputScheme?: OperationInputInfoList;
+ inputValidator?: (
+ inputs: MapOperationInputInfoValueTypeList<OperationInputInfoList>
+ ) => 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<TData, OperationInputInfoList>
+): 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<Step>({ type: "input" });
+
+ type ValueType = boolean | string | null | undefined;
+
+ const [values, setValues] = useState<ValueType[]>(
+ 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<boolean[]>(() =>
+ inputScheme.map(() => false)
+ );
+ const [inputError, setInputError] = useState<OperationInputError>();
+
+ 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<OperationInputInfoList>
+ )
+ .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 = <h6>{inputPrompt}</h6>;
+
+ const validate = (values: ValueType[]): boolean => {
+ const { inputValidator } = props;
+ if (inputValidator != null) {
+ const result = inputValidator(
+ values as unknown as MapOperationInputInfoValueTypeList<OperationInputInfoList>
+ );
+ 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 = (
+ <>
+ <div>
+ {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 (
+ <div
+ key={index}
+ className={classNames(
+ "cru-operation-dialog-group",
+ error != null ? "error" : null
+ )}
+ >
+ {item.label && (
+ <label className="cru-operation-dialog-label">
+ {convertI18nText(item.label, t)}
+ </label>
+ )}
+ <input
+ type={item.password === true ? "password" : "text"}
+ value={value as string}
+ onChange={(e) => {
+ const v = e.target.value;
+ updateValue(index, v);
+ }}
+ disabled={process}
+ />
+ {error != null && (
+ <div className="cru-operation-dialog-error-text">
+ {error}
+ </div>
+ )}
+ {item.helperText && (
+ <div className="cru-operation-dialog-helper-text">
+ {t(item.helperText)}
+ </div>
+ )}
+ </div>
+ );
+ } else if (item.type === "bool") {
+ return (
+ <div
+ key={index}
+ className={classNames(
+ "cru-operation-dialog-group",
+ error != null ? "error" : null
+ )}
+ >
+ <input
+ type="checkbox"
+ checked={value as boolean}
+ onChange={(event) => {
+ updateValue(index, event.currentTarget.checked);
+ }}
+ disabled={process}
+ />
+ <label className="cru-operation-dialog-inline-label">
+ {convertI18nText(item.label, t)}
+ </label>
+ {error != null && (
+ <div className="cru-operation-dialog-error-text">
+ {error}
+ </div>
+ )}
+ {item.helperText && (
+ <div className="cru-operation-dialog-helper-text">
+ {t(item.helperText)}
+ </div>
+ )}
+ </div>
+ );
+ } else if (item.type === "select") {
+ return (
+ <div
+ key={index}
+ className={classNames(
+ "cru-operation-dialog-group",
+ error != null ? "error" : null
+ )}
+ >
+ <label className="cru-operation-dialog-label">
+ {convertI18nText(item.label, t)}
+ </label>
+ <select
+ value={value as string}
+ onChange={(event) => {
+ updateValue(index, event.target.value);
+ }}
+ disabled={process}
+ >
+ {item.options.map((option, i) => {
+ return (
+ <option value={option.value} key={i}>
+ {option.icon}
+ {convertI18nText(option.label, t)}
+ </option>
+ );
+ })}
+ </select>
+ </div>
+ );
+ } else if (item.type === "color") {
+ return (
+ <div
+ key={index}
+ className={classNames(
+ "cru-operation-dialog-group",
+ error != null ? "error" : null
+ )}
+ >
+ {item.canBeNull ? (
+ <input
+ type="checkbox"
+ checked={value !== null}
+ onChange={(event) => {
+ if (event.currentTarget.checked) {
+ updateValue(index, "#007bff");
+ } else {
+ updateValue(index, null);
+ }
+ }}
+ disabled={process}
+ />
+ ) : null}
+ <label className="cru-operation-dialog-inline-label">
+ {convertI18nText(item.label, t)}
+ </label>
+ {value !== null && (
+ <TwitterPicker
+ color={value as string}
+ triangle="hide"
+ onChange={(result) => updateValue(index, result.hex)}
+ />
+ )}
+ </div>
+ );
+ } else if (item.type === "datetime") {
+ return (
+ <div
+ key={index}
+ className={classNames(
+ "cru-operation-dialog-group",
+ error != null ? "error" : null
+ )}
+ >
+ {item.label && (
+ <label className="cru-operation-dialog-label">
+ {convertI18nText(item.label, t)}
+ </label>
+ )}
+ <input
+ type="datetime-local"
+ value={value as string}
+ onChange={(e) => {
+ const v = e.target.value;
+ updateValue(index, v);
+ }}
+ disabled={process}
+ />
+ {error != null && <div>{error}</div>}
+ </div>
+ );
+ }
+ })}
+ </div>
+ <hr />
+ <div className="cru-dialog-bottom-area">
+ <Button
+ text="operationDialog.cancel"
+ color="secondary"
+ outline
+ onClick={close}
+ disabled={process}
+ />
+ <LoadingButton
+ color={props.themeColor}
+ loading={process}
+ disabled={!canProcess}
+ onClick={() => {
+ setDirtyList(inputScheme.map(() => true));
+ if (validate(values)) {
+ onConfirm();
+ }
+ }}
+ >
+ {t("operationDialog.confirm")}
+ </LoadingButton>
+ </div>
+ </>
+ );
+ } 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 = <p className="cru-color-success">{content}</p>;
+ } else {
+ content = props.failurePrompt?.(result.data) ?? <DefaultErrorPrompt />;
+ if (typeof content === "string")
+ content = <DefaultErrorPrompt error={content} />;
+ }
+ body = (
+ <>
+ <div>{content}</div>
+ <hr />
+ <div className="cru-dialog-bottom-area">
+ <Button text="operationDialog.ok" color="primary" onClick={close} />
+ </div>
+ </>
+ );
+ }
+
+ const title =
+ typeof props.title === "function"
+ ? props.title()
+ : convertI18nText(props.title, t);
+
+ return (
+ <Dialog open={props.open} onClose={close}>
+ <h3
+ className={
+ props.themeColor != null
+ ? "cru-color-" + props.themeColor
+ : "cru-color-primary"
+ }
+ >
+ {title}
+ </h3>
+ <hr />
+ {body}
+ </Dialog>
+ );
+};
+
+export default OperationDialog;