aboutsummaryrefslogtreecommitdiff
path: root/FrontEnd/src/components/dialog
diff options
context:
space:
mode:
authorcrupest <crupest@outlook.com>2023-08-26 21:36:58 +0800
committercrupest <crupest@outlook.com>2023-08-26 21:36:58 +0800
commitf5dfd52f6efece2f4cad227044ecf4dd66301bbc (patch)
tree0d64daae438ac6d95f0848a0b3387134d9528438 /FrontEnd/src/components/dialog
parent4daa84ede781cdf6f424d68c967a17c7e1c79f59 (diff)
downloadtimeline-f5dfd52f6efece2f4cad227044ecf4dd66301bbc.tar.gz
timeline-f5dfd52f6efece2f4cad227044ecf4dd66301bbc.tar.bz2
timeline-f5dfd52f6efece2f4cad227044ecf4dd66301bbc.zip
...
Diffstat (limited to 'FrontEnd/src/components/dialog')
-rw-r--r--FrontEnd/src/components/dialog/ConfirmDialog.css0
-rw-r--r--FrontEnd/src/components/dialog/ConfirmDialog.tsx59
-rw-r--r--FrontEnd/src/components/dialog/Dialog.css60
-rw-r--r--FrontEnd/src/components/dialog/Dialog.tsx63
-rw-r--r--FrontEnd/src/components/dialog/DialogContainer.css20
-rw-r--r--FrontEnd/src/components/dialog/DialogContainer.tsx95
-rw-r--r--FrontEnd/src/components/dialog/FullPageDialog.css44
-rw-r--r--FrontEnd/src/components/dialog/FullPageDialog.tsx53
-rw-r--r--FrontEnd/src/components/dialog/OperationDialog.css8
-rw-r--r--FrontEnd/src/components/dialog/OperationDialog.tsx230
-rw-r--r--FrontEnd/src/components/dialog/index.ts64
11 files changed, 696 insertions, 0 deletions
diff --git a/FrontEnd/src/components/dialog/ConfirmDialog.css b/FrontEnd/src/components/dialog/ConfirmDialog.css
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/FrontEnd/src/components/dialog/ConfirmDialog.css
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 (
+ <Dialog onClose={onClose} open={open}>
+ <DialogContainer
+ title={title}
+ titleColor={color ?? "danger"}
+ buttons={[
+ {
+ key: "cancel",
+ type: "normal",
+ props: {
+ text: "operationDialog.cancel",
+ color: "secondary",
+ outline: true,
+ onClick: onClose,
+ },
+ },
+ {
+ key: "confirm",
+ type: "normal",
+ props: {
+ text: "operationDialog.confirm",
+ color: "danger",
+ onClick: () => {
+ onConfirm();
+ onClose();
+ },
+ },
+ },
+ ]}
+ >
+ <div className={`cru-${bodyColor ?? "primary"}`}>{c(body)}</div>
+ </DialogContainer>
+ </Dialog>
+ );
+}
diff --git a/FrontEnd/src/components/dialog/Dialog.css b/FrontEnd/src/components/dialog/Dialog.css
new file mode 100644
index 00000000..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(
+ <CSSTransition
+ nodeRef={nodeRef}
+ mountOnEnter
+ unmountOnExit
+ in={open}
+ timeout={300}
+ classNames="cru-dialog"
+ >
+ <div
+ ref={nodeRef}
+ className={classNames("cru-dialog-overlay", `cru-${color}`)}
+ >
+ <div
+ className="cru-dialog-background"
+ onClick={
+ disableCloseOnClickOnOverlay
+ ? undefined
+ : () => {
+ onClose();
+ }
+ }
+ />
+ <div className="cru-dialog-container">{children}</div>
+ </div>
+ </CSSTransition>,
+ 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<HTMLDivElement>;
+ bodyContainerClassName?: string;
+ bodyContainerRef?: Ref<HTMLDivElement>;
+ buttonsClassName?: string;
+ buttonsContainerRef?: ComponentProps<typeof ButtonRow>["containerRef"];
+ children: ReactNode;
+}
+
+interface DialogContainerWithButtonsProps extends DialogContainerBaseProps {
+ buttons: ComponentProps<typeof ButtonRow>["buttons"];
+}
+
+interface DialogContainerWithButtonsV2Props extends DialogContainerBaseProps {
+ buttonsV2: ComponentProps<typeof ButtonRowV2>["buttons"];
+}
+
+type DialogContainerProps =
+ | DialogContainerWithButtonsProps
+ | DialogContainerWithButtonsV2Props;
+
+export default function DialogContainer(props: DialogContainerProps) {
+ const {
+ className,
+ title,
+ titleColor,
+ titleClassName,
+ titleRef,
+ bodyContainerClassName,
+ bodyContainerRef,
+ buttonsClassName,
+ buttonsContainerRef,
+ children,
+ } = props;
+
+ const c = useC();
+
+ return (
+ <div className={classNames(className)}>
+ <div
+ ref={titleRef}
+ className={classNames(
+ `cru-dialog-container-title cru-${titleColor ?? "primary"}`,
+ titleClassName,
+ )}
+ >
+ {c(title)}
+ </div>
+ <hr className="cru-dialog-container-hr" />
+ <div
+ ref={bodyContainerRef}
+ className={classNames(
+ "cru-dialog-container-body",
+ bodyContainerClassName,
+ )}
+ >
+ {children}
+ </div>
+ <hr className="cru-dialog-container-hr" />
+ {"buttons" in props ? (
+ <ButtonRow
+ containerRef={buttonsContainerRef}
+ className={classNames(
+ "cru-dialog-container-button-row",
+ buttonsClassName,
+ )}
+ buttons={props.buttons}
+ buttonsClassName="cru-dialog-container-button"
+ />
+ ) : (
+ <ButtonRowV2
+ containerRef={buttonsContainerRef}
+ className={classNames(
+ "cru-dialog-container-button-row",
+ buttonsClassName,
+ )}
+ buttons={props.buttonsV2}
+ buttonsClassName="cru-dialog-container-button"
+ />
+ )}
+ </div>
+ );
+}
diff --git a/FrontEnd/src/components/dialog/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<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/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 (
+ <div className={classNames(className, "cru-operation-dialog-prompt")}>
+ {message && <p>{c(message)}</p>}
+ {customMessageNode ?? (customMessage != null ? c(customMessage) : null)}
+ </div>
+ );
+}
+
+export interface OperationDialogProps<TData> {
+ 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<TData>;
+ onSuccessAndClose?: (data: TData) => void;
+}
+
+function OperationDialog<TData>(props: OperationDialogProps<TData>) {
+ 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<Step>({ 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<typeof ButtonRow>["buttons"];
+
+ if (step.type === "input" || step.type === "process") {
+ const isProcessing = step.type === "process";
+
+ body = (
+ <div>
+ <OperationDialogPrompt
+ customMessage={inputPrompt}
+ customMessageNode={inputPromptNode}
+ />
+ <InputGroup
+ containerClassName="cru-operation-dialog-input-group"
+ color={inputColor ?? "primary"}
+ {...inputGroupProps}
+ />
+ </div>
+ );
+ buttons = [
+ {
+ key: "cancel",
+ 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 = (
+ <div>
+ <OperationDialogPrompt {...promptProps} />
+ </div>
+ );
+
+ buttons = [
+ {
+ key: "ok",
+ type: "normal",
+ props: {
+ text: "operationDialog.ok",
+ color: "primary",
+ onClick: close,
+ },
+ },
+ ];
+ }
+
+ return (
+ <Dialog open={open} onClose={close}>
+ <DialogContainer title={title} titleColor={color} buttons={buttons}>
+ {body}
+ </DialogContainer>
+ </Dialog>
+ );
+}
+
+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<D extends string, V> = {
+ [K in D]: V;
+};
+
+type DialogKeyMap<D extends string> = DialogMap<D, number>;
+
+type DialogPropsMap<D extends string> = DialogMap<
+ D,
+ { key: number | string; open: boolean; onClose: () => void }
+>;
+
+export function useDialog<D extends string>(
+ dialogs: D[],
+ options?: {
+ initDialog?: D | null;
+ onClose?: {
+ [K in D]?: () => void;
+ };
+ },
+): {
+ dialog: D | null;
+ switchDialog: (newDialog: D | null) => void;
+ dialogPropsMap: DialogPropsMap<D>;
+ createDialogSwitch: (newDialog: D | null) => () => void;
+} {
+ const [dialog, setDialog] = useState<D | null>(options?.initDialog ?? null);
+
+ const [dialogKeys, setDialogKeys] = useState<DialogKeyMap<D>>(
+ () => Object.fromEntries(dialogs.map((d) => [d, 0])) as DialogKeyMap<D>,
+ );
+
+ 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<D>,
+ createDialogSwitch: (newDialog: D | null) => () => switchDialog(newDialog),
+ };
+}