mirror of
https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web.git
synced 2025-05-19 04:00:16 +09:00
582 lines
14 KiB
TypeScript
582 lines
14 KiB
TypeScript
/* eslint-disable @next/next/no-img-element */
|
||
import styles from "./ui-lib.module.scss";
|
||
import LoadingIcon from "../icons/three-dots.svg";
|
||
import CloseIcon from "../icons/close.svg";
|
||
import EyeIcon from "../icons/eye.svg";
|
||
import EyeOffIcon from "../icons/eye-off.svg";
|
||
import DownIcon from "../icons/down.svg";
|
||
import ConfirmIcon from "../icons/confirm.svg";
|
||
import CancelIcon from "../icons/cancel.svg";
|
||
import MaxIcon from "../icons/max.svg";
|
||
import MinIcon from "../icons/min.svg";
|
||
|
||
import Locale from "../locales";
|
||
|
||
import { createRoot } from "react-dom/client";
|
||
import React, {
|
||
CSSProperties,
|
||
HTMLProps,
|
||
MouseEvent,
|
||
useEffect,
|
||
useState,
|
||
useCallback,
|
||
useRef,
|
||
} from "react";
|
||
import { IconButton } from "./button";
|
||
|
||
export function Popover(props: {
|
||
children: JSX.Element;
|
||
content: JSX.Element;
|
||
open?: boolean;
|
||
onClose?: () => void;
|
||
}) {
|
||
return (
|
||
<div className={styles.popover}>
|
||
{props.children}
|
||
{props.open && (
|
||
<div className={styles["popover-mask"]} onClick={props.onClose}></div>
|
||
)}
|
||
{props.open && (
|
||
<div className={styles["popover-content"]}>{props.content}</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function Card(props: { children: JSX.Element[]; className?: string }) {
|
||
return (
|
||
<div className={styles.card + " " + props.className}>{props.children}</div>
|
||
);
|
||
}
|
||
|
||
export function ListItem(props: {
|
||
title: string;
|
||
subTitle?: string;
|
||
children?: JSX.Element | JSX.Element[];
|
||
icon?: JSX.Element;
|
||
className?: string;
|
||
onClick?: (e: MouseEvent) => void;
|
||
vertical?: boolean;
|
||
}) {
|
||
return (
|
||
<div
|
||
className={
|
||
styles["list-item"] +
|
||
` ${props.vertical ? styles["vertical"] : ""} ` +
|
||
` ${props.className || ""}`
|
||
}
|
||
onClick={props.onClick}
|
||
>
|
||
<div className={styles["list-header"]}>
|
||
{props.icon && <div className={styles["list-icon"]}>{props.icon}</div>}
|
||
<div className={styles["list-item-title"]}>
|
||
<div>{props.title}</div>
|
||
{props.subTitle && (
|
||
<div className={styles["list-item-sub-title"]}>
|
||
{props.subTitle}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{props.children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function List(props: { children: React.ReactNode; id?: string }) {
|
||
return (
|
||
<div className={styles.list} id={props.id}>
|
||
{props.children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function Loading() {
|
||
return (
|
||
<div
|
||
style={{
|
||
height: "100vh",
|
||
width: "100vw",
|
||
display: "flex",
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
}}
|
||
>
|
||
<LoadingIcon />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
interface ModalProps {
|
||
title: string;
|
||
children?: any;
|
||
actions?: React.ReactNode[];
|
||
defaultMax?: boolean;
|
||
footer?: React.ReactNode;
|
||
onClose?: () => void;
|
||
}
|
||
export function Modal(props: ModalProps) {
|
||
useEffect(() => {
|
||
const onKeyDown = (e: KeyboardEvent) => {
|
||
if (e.key === "Escape") {
|
||
props.onClose?.();
|
||
}
|
||
};
|
||
|
||
window.addEventListener("keydown", onKeyDown);
|
||
|
||
return () => {
|
||
window.removeEventListener("keydown", onKeyDown);
|
||
};
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
const [isMax, setMax] = useState(!!props.defaultMax);
|
||
|
||
return (
|
||
<div
|
||
className={
|
||
styles["modal-container"] + ` ${isMax && styles["modal-container-max"]}`
|
||
}
|
||
>
|
||
<div className={styles["modal-header"]}>
|
||
<div className={styles["modal-title"]}>{props.title}</div>
|
||
|
||
<div className={styles["modal-header-actions"]}>
|
||
<div
|
||
className={styles["modal-header-action"]}
|
||
onClick={() => setMax(!isMax)}
|
||
>
|
||
{isMax ? <MinIcon /> : <MaxIcon />}
|
||
</div>
|
||
<div
|
||
className={styles["modal-header-action"]}
|
||
onClick={props.onClose}
|
||
>
|
||
<CloseIcon />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className={styles["modal-content"]}>{props.children}</div>
|
||
|
||
<div className={styles["modal-footer"]}>
|
||
{props.footer}
|
||
<div className={styles["modal-actions"]}>
|
||
{props.actions?.map((action, i) => (
|
||
<div key={i} className={styles["modal-action"]}>
|
||
{action}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function showModal(props: ModalProps) {
|
||
const div = document.createElement("div");
|
||
div.className = "modal-mask";
|
||
document.body.appendChild(div);
|
||
|
||
const root = createRoot(div);
|
||
const closeModal = () => {
|
||
props.onClose?.();
|
||
root.unmount();
|
||
div.remove();
|
||
};
|
||
|
||
div.onclick = (e) => {
|
||
if (e.target === div) {
|
||
closeModal();
|
||
}
|
||
};
|
||
|
||
root.render(<Modal {...props} onClose={closeModal}></Modal>);
|
||
}
|
||
|
||
export type ToastProps = {
|
||
content: string;
|
||
action?: {
|
||
text: string;
|
||
onClick: () => void;
|
||
};
|
||
onClose?: () => void;
|
||
};
|
||
|
||
export function Toast(props: ToastProps) {
|
||
return (
|
||
<div className={styles["toast-container"]}>
|
||
<div className={styles["toast-content"]}>
|
||
<span>{props.content}</span>
|
||
{props.action && (
|
||
<button
|
||
onClick={() => {
|
||
props.action?.onClick?.();
|
||
props.onClose?.();
|
||
}}
|
||
className={styles["toast-action"]}
|
||
>
|
||
{props.action.text}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function showToast(
|
||
content: string,
|
||
action?: ToastProps["action"],
|
||
delay = 3000,
|
||
) {
|
||
const div = document.createElement("div");
|
||
div.className = styles.show;
|
||
document.body.appendChild(div);
|
||
|
||
const root = createRoot(div);
|
||
const close = () => {
|
||
div.classList.add(styles.hide);
|
||
|
||
setTimeout(() => {
|
||
root.unmount();
|
||
div.remove();
|
||
}, 300);
|
||
};
|
||
|
||
setTimeout(() => {
|
||
close();
|
||
}, delay);
|
||
|
||
root.render(<Toast content={content} action={action} onClose={close} />);
|
||
}
|
||
|
||
export type InputProps = React.HTMLProps<HTMLTextAreaElement> & {
|
||
autoHeight?: boolean;
|
||
rows?: number;
|
||
};
|
||
|
||
export function Input(props: InputProps) {
|
||
return (
|
||
<textarea
|
||
{...props}
|
||
className={`${styles["input"]} ${props.className}`}
|
||
></textarea>
|
||
);
|
||
}
|
||
|
||
// 定义一个接口 AriaProps,包含一个可选的 aria 属性
|
||
interface AriaProps {
|
||
aria?: string;
|
||
}
|
||
|
||
// 定义一个接口 PasswordInputProps,继承自 HTMLProps<HTMLInputElement> 和 AriaProps
|
||
// 用于描述密码输入框组件的属性
|
||
interface PasswordInputProps extends HTMLProps<HTMLInputElement>, AriaProps {}
|
||
|
||
export function PasswordInput(props: PasswordInputProps) {
|
||
const [visible, setVisible] = useState(false);
|
||
function changeVisibility() {
|
||
setVisible(!visible);
|
||
}
|
||
|
||
return (
|
||
<div className={"password-input-container"}>
|
||
<IconButton
|
||
aria={props.aria}
|
||
icon={visible ? <EyeIcon /> : <EyeOffIcon />}
|
||
onClick={changeVisibility}
|
||
className={"password-eye"}
|
||
/>
|
||
<input
|
||
{...props}
|
||
type={visible ? "text" : "password"}
|
||
className={"password-input"}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function Select(
|
||
props: React.DetailedHTMLProps<
|
||
React.SelectHTMLAttributes<HTMLSelectElement>,
|
||
HTMLSelectElement
|
||
>,
|
||
) {
|
||
const { className, children, ...otherProps } = props;
|
||
return (
|
||
<div className={`${styles["select-with-icon"]} ${className}`}>
|
||
<select className={styles["select-with-icon-select"]} {...otherProps}>
|
||
{children}
|
||
</select>
|
||
<DownIcon className={styles["select-with-icon-icon"]} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function showConfirm(content: any) {
|
||
const div = document.createElement("div");
|
||
div.className = "modal-mask";
|
||
document.body.appendChild(div);
|
||
|
||
const root = createRoot(div);
|
||
const closeModal = () => {
|
||
root.unmount();
|
||
div.remove();
|
||
};
|
||
|
||
return new Promise<boolean>((resolve) => {
|
||
root.render(
|
||
<Modal
|
||
title={Locale.UI.Confirm}
|
||
actions={[
|
||
<IconButton
|
||
key="cancel"
|
||
text={Locale.UI.Cancel}
|
||
onClick={() => {
|
||
resolve(false);
|
||
closeModal();
|
||
}}
|
||
icon={<CancelIcon />}
|
||
tabIndex={0}
|
||
bordered
|
||
shadow
|
||
></IconButton>,
|
||
<IconButton
|
||
key="confirm"
|
||
text={Locale.UI.Confirm}
|
||
type="primary"
|
||
onClick={() => {
|
||
resolve(true);
|
||
closeModal();
|
||
}}
|
||
icon={<ConfirmIcon />}
|
||
tabIndex={0}
|
||
autoFocus
|
||
bordered
|
||
shadow
|
||
></IconButton>,
|
||
]}
|
||
onClose={closeModal}
|
||
>
|
||
{content}
|
||
</Modal>,
|
||
);
|
||
});
|
||
}
|
||
|
||
function PromptInput(props: {
|
||
value: string;
|
||
onChange: (value: string) => void;
|
||
rows?: number;
|
||
}) {
|
||
const [input, setInput] = useState(props.value);
|
||
const onInput = (value: string) => {
|
||
props.onChange(value);
|
||
setInput(value);
|
||
};
|
||
|
||
return (
|
||
<textarea
|
||
className={styles["modal-input"]}
|
||
autoFocus
|
||
value={input}
|
||
onInput={(e) => onInput(e.currentTarget.value)}
|
||
rows={props.rows ?? 3}
|
||
></textarea>
|
||
);
|
||
}
|
||
|
||
export function showPrompt(content: any, value = "", rows = 3) {
|
||
const div = document.createElement("div");
|
||
div.className = "modal-mask";
|
||
document.body.appendChild(div);
|
||
|
||
const root = createRoot(div);
|
||
const closeModal = () => {
|
||
root.unmount();
|
||
div.remove();
|
||
};
|
||
|
||
return new Promise<string>((resolve) => {
|
||
let userInput = value;
|
||
|
||
root.render(
|
||
<Modal
|
||
title={content}
|
||
actions={[
|
||
<IconButton
|
||
key="cancel"
|
||
text={Locale.UI.Cancel}
|
||
onClick={() => {
|
||
closeModal();
|
||
}}
|
||
icon={<CancelIcon />}
|
||
bordered
|
||
shadow
|
||
tabIndex={0}
|
||
></IconButton>,
|
||
<IconButton
|
||
key="confirm"
|
||
text={Locale.UI.Confirm}
|
||
type="primary"
|
||
onClick={() => {
|
||
resolve(userInput);
|
||
closeModal();
|
||
}}
|
||
icon={<ConfirmIcon />}
|
||
bordered
|
||
shadow
|
||
tabIndex={0}
|
||
></IconButton>,
|
||
]}
|
||
onClose={closeModal}
|
||
>
|
||
<PromptInput
|
||
onChange={(val) => (userInput = val)}
|
||
value={value}
|
||
rows={rows}
|
||
></PromptInput>
|
||
</Modal>,
|
||
);
|
||
});
|
||
}
|
||
|
||
export function showImageModal(
|
||
img: string,
|
||
defaultMax?: boolean,
|
||
style?: CSSProperties,
|
||
boxStyle?: CSSProperties,
|
||
) {
|
||
showModal({
|
||
title: Locale.Export.Image.Modal,
|
||
defaultMax: defaultMax,
|
||
children: (
|
||
<div style={{ display: "flex", justifyContent: "center", ...boxStyle }}>
|
||
<img
|
||
src={img}
|
||
alt="preview"
|
||
style={
|
||
style ?? {
|
||
maxWidth: "100%",
|
||
}
|
||
}
|
||
></img>
|
||
</div>
|
||
),
|
||
});
|
||
}
|
||
|
||
export function Selector<T>(props: {
|
||
items: Array<{
|
||
title: string;
|
||
subTitle?: string;
|
||
value: T;
|
||
disable?: boolean;
|
||
}>;
|
||
defaultSelectedValue?: T[] | T;
|
||
onSelection?: (selection: T[]) => void;
|
||
onClose?: () => void;
|
||
multiple?: boolean;
|
||
}) {
|
||
const [selectedValues, setSelectedValues] = useState<T[]>(
|
||
Array.isArray(props.defaultSelectedValue)
|
||
? props.defaultSelectedValue
|
||
: props.defaultSelectedValue !== undefined
|
||
? [props.defaultSelectedValue]
|
||
: [],
|
||
);
|
||
|
||
const handleSelection = (e: MouseEvent, value: T) => {
|
||
if (props.multiple) {
|
||
e.stopPropagation();
|
||
const newSelectedValues = selectedValues.includes(value)
|
||
? selectedValues.filter((v) => v !== value)
|
||
: [...selectedValues, value];
|
||
setSelectedValues(newSelectedValues);
|
||
props.onSelection?.(newSelectedValues);
|
||
} else {
|
||
setSelectedValues([value]);
|
||
props.onSelection?.([value]);
|
||
props.onClose?.();
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className={styles["selector"]} onClick={() => props.onClose?.()}>
|
||
<div className={styles["selector-content"]}>
|
||
<List>
|
||
{props.items.map((item, i) => {
|
||
const selected = selectedValues.includes(item.value);
|
||
return (
|
||
<ListItem
|
||
className={`${styles["selector-item"]} ${
|
||
item.disable && styles["selector-item-disabled"]
|
||
}`}
|
||
key={i}
|
||
title={item.title}
|
||
subTitle={item.subTitle}
|
||
onClick={(e) => {
|
||
if (item.disable) {
|
||
e.stopPropagation();
|
||
} else {
|
||
handleSelection(e, item.value);
|
||
}
|
||
}}
|
||
>
|
||
{selected ? (
|
||
<div
|
||
style={{
|
||
height: 10,
|
||
width: 10,
|
||
backgroundColor: "var(--primary)",
|
||
borderRadius: 10,
|
||
}}
|
||
></div>
|
||
) : (
|
||
<></>
|
||
)}
|
||
</ListItem>
|
||
);
|
||
})}
|
||
</List>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
export function FullScreen(props: any) {
|
||
const { children, right = 10, top = 10, ...rest } = props;
|
||
const ref = useRef<HTMLDivElement>();
|
||
const [fullScreen, setFullScreen] = useState(false);
|
||
const toggleFullscreen = useCallback(() => {
|
||
if (!document.fullscreenElement) {
|
||
ref.current?.requestFullscreen();
|
||
} else {
|
||
document.exitFullscreen();
|
||
}
|
||
}, []);
|
||
useEffect(() => {
|
||
const handleScreenChange = (e: any) => {
|
||
if (e.target === ref.current) {
|
||
setFullScreen(!!document.fullscreenElement);
|
||
}
|
||
};
|
||
document.addEventListener("fullscreenchange", handleScreenChange);
|
||
return () => {
|
||
document.removeEventListener("fullscreenchange", handleScreenChange);
|
||
};
|
||
}, []);
|
||
return (
|
||
<div ref={ref} style={{ position: "relative" }} {...rest}>
|
||
<div style={{ position: "absolute", right, top }}>
|
||
<IconButton
|
||
icon={fullScreen ? <MinIcon /> : <MaxIcon />}
|
||
onClick={toggleFullscreen}
|
||
bordered
|
||
/>
|
||
</div>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|