ChatGPT-Next-Web/app/components/ui-lib.tsx

600 lines
14 KiB
TypeScript

import type {
CSSProperties,
HTMLProps,
MouseEvent,
} from 'react';
import clsx from 'clsx';
import React, {
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { createRoot } from 'react-dom/client';
import CancelIcon from '../icons/cancel.svg';
import CloseIcon from '../icons/close.svg';
import ConfirmIcon from '../icons/confirm.svg';
import DownIcon from '../icons/down.svg';
import EyeIcon from '../icons/eye.svg';
import EyeOffIcon from '../icons/eye-off.svg';
import MaxIcon from '../icons/max.svg';
import MinIcon from '../icons/min.svg';
import LoadingIcon from '../icons/three-dots.svg';
import Locale from '../locales';
import { IconButton } from './button';
import styles from './ui-lib.module.scss';
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={clsx(styles.card, props.className)}>{props.children}</div>
);
}
export function ListItem(props: {
title?: string;
subTitle?: string | JSX.Element;
children?: JSX.Element | JSX.Element[];
icon?: JSX.Element;
className?: string;
onClick?: (e: MouseEvent) => void;
vertical?: boolean;
}) {
return (
<div
className={clsx(
styles['list-item'],
{
[styles.vertical]: props.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={clsx(styles['modal-container'], {
[styles['modal-container-max']]: isMax,
})}
>
<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 interface 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={clsx(styles.input, props.className)}
>
</textarea>
);
}
export function PasswordInput(
props: HTMLProps<HTMLInputElement> & { aria?: string },
) {
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> & {
align?: 'left' | 'center';
},
HTMLSelectElement
>,
) {
const { className, children, align, ...otherProps } = props;
return (
<div
className={clsx(
styles['select-with-icon'],
{
[styles['left-align-option']]: align === 'left',
},
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,
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={clsx(styles['selector-item'], {
[styles['selector-item-disabled']]: item.disable,
})}
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>
);
}